adapt-authoring-adaptframework 3.0.1 → 3.1.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/lib/AdaptFrameworkImport.js +11 -4
- package/lib/AdaptFrameworkModule.js +14 -1
- package/lib/utils/applyContentAccessFilter.js +26 -0
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/tests/AdaptFrameworkImport.spec.js +21 -2
- package/tests/utils-applyContentAccessFilter.spec.js +52 -0
|
@@ -169,6 +169,11 @@ class AdaptFrameworkImport {
|
|
|
169
169
|
* @type {Array<String>}
|
|
170
170
|
*/
|
|
171
171
|
this.newTagIds = []
|
|
172
|
+
/**
|
|
173
|
+
* Array of asset IDs newly created during import for rollback. Excludes assets de-duplicated to existing records.
|
|
174
|
+
* @type {Array<String>}
|
|
175
|
+
*/
|
|
176
|
+
this.newAssetIds = []
|
|
172
177
|
/**
|
|
173
178
|
* Contains non-fatal infomation messages regarding import status which can be return as response data. Fatal errors are thrown in the usual way.
|
|
174
179
|
* @type {Object}
|
|
@@ -595,7 +600,9 @@ class AdaptFrameworkImport {
|
|
|
595
600
|
})
|
|
596
601
|
// store the asset _id so we can map it to the old path later
|
|
597
602
|
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
598
|
-
|
|
603
|
+
const assetId = asset._id.toString()
|
|
604
|
+
this.assetMap[resolved] = assetId
|
|
605
|
+
this.newAssetIds.push(assetId)
|
|
599
606
|
} catch (e) {
|
|
600
607
|
if (e.code === 'DUPLICATE_ASSET') {
|
|
601
608
|
const resolved = path.relative(`${this.coursePath}/..`, filepath)
|
|
@@ -902,9 +909,9 @@ class AdaptFrameworkImport {
|
|
|
902
909
|
if (Object.keys(this.updatedContentPlugins).length) {
|
|
903
910
|
tasks.push(this.restoreUpdatedPlugins())
|
|
904
911
|
}
|
|
905
|
-
// Delete
|
|
906
|
-
if (this.assets) {
|
|
907
|
-
tasks.push(...
|
|
912
|
+
// Delete newly created assets (skip de-duplicated assets which point to pre-existing records)
|
|
913
|
+
if (this.assets && this.newAssetIds.length) {
|
|
914
|
+
tasks.push(...this.newAssetIds.map(id =>
|
|
908
915
|
this.assets.delete({ _id: id })
|
|
909
916
|
.catch(e => log('warn', `failed to delete asset '${id}'`, e))
|
|
910
917
|
))
|
|
@@ -4,7 +4,7 @@ import AdaptFrameworkImport from './AdaptFrameworkImport.js'
|
|
|
4
4
|
import fs from 'node:fs/promises'
|
|
5
5
|
import { getHandler, postHandler, importHandler, postUpdateHandler, getUpdateHandler } from './handlers.js'
|
|
6
6
|
import { loadRouteConfig, registerRoutes } from 'adapt-authoring-server'
|
|
7
|
-
import { runCliCommand, readFrameworkPluginVersions, migrateExistingCourses, computePluginHash, prebuildCache } from './utils.js'
|
|
7
|
+
import { applyContentAccessFilter, runCliCommand, readFrameworkPluginVersions, migrateExistingCourses, computePluginHash, prebuildCache } from './utils.js'
|
|
8
8
|
import BuildCache from './BuildCache.js'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import semver from 'semver'
|
|
@@ -79,6 +79,7 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
79
79
|
this._targetFrameworkVersion = meta.framework?.targetVersion
|
|
80
80
|
|
|
81
81
|
this.app.waitForModule('content').then(content => {
|
|
82
|
+
content.accessQueryHook.tap(this.onContentAccessQueryHook.bind(this))
|
|
82
83
|
content.accessCheckHook.tap(this.checkContentAccess.bind(this))
|
|
83
84
|
})
|
|
84
85
|
|
|
@@ -319,6 +320,18 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
319
320
|
schemas.forEach(s => jsonschema.registerSchema(s))
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Merges ownership/sharing access clauses into the content query when listing courses,
|
|
325
|
+
* so the database returns only courses the user can see. Non-course queries fall through
|
|
326
|
+
* to the per-item `checkContentAccess` safety net (the parent course's `_id` is not in
|
|
327
|
+
* scope at this stage).
|
|
328
|
+
* @param {external:ExpressRequest} req
|
|
329
|
+
*/
|
|
330
|
+
async onContentAccessQueryHook (req) {
|
|
331
|
+
if (req.apiData.query._type !== 'course') return
|
|
332
|
+
applyContentAccessFilter(req.apiData.query, req.auth.user._id.toString())
|
|
333
|
+
}
|
|
334
|
+
|
|
322
335
|
/**
|
|
323
336
|
* Checks whether the request user should be given access to the content they're requesting
|
|
324
337
|
* @param {external:ExpressRequest} req
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutates a mongo content query so it only matches courses the given user can access:
|
|
3
|
+
* owned by them, marked public via `_isShared`, or in `_shareWithUsers`. Combines safely
|
|
4
|
+
* with an existing `$or` (e.g. from search) by lifting both into `$and`.
|
|
5
|
+
* @param {object} query The mongo query object to mutate
|
|
6
|
+
* @param {string} userId The user's `_id` (already coerced to string)
|
|
7
|
+
* @memberof adaptframework
|
|
8
|
+
*/
|
|
9
|
+
export function applyContentAccessFilter (query, userId) {
|
|
10
|
+
if (!userId) return
|
|
11
|
+
const clauses = [
|
|
12
|
+
{ createdBy: userId },
|
|
13
|
+
{ _isShared: true },
|
|
14
|
+
{ _shareWithUsers: userId }
|
|
15
|
+
]
|
|
16
|
+
if (query.$or) {
|
|
17
|
+
query.$and = [
|
|
18
|
+
...(query.$and ?? []),
|
|
19
|
+
{ $or: query.$or },
|
|
20
|
+
{ $or: clauses }
|
|
21
|
+
]
|
|
22
|
+
delete query.$or
|
|
23
|
+
} else {
|
|
24
|
+
query.$or = clauses
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { applyContentAccessFilter } from './utils/applyContentAccessFilter.js'
|
|
1
2
|
export { inferBuildAction } from './utils/inferBuildAction.js'
|
|
2
3
|
export { getPluginUpdateStatus } from './utils/getPluginUpdateStatus.js'
|
|
3
4
|
export { getImportContentCounts } from './utils/getImportContentCounts.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-adaptframework",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Adapt framework integration for the Adapt authoring tool",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -404,6 +404,7 @@ describe('AdaptFrameworkImport', () => {
|
|
|
404
404
|
newContentPlugins: {},
|
|
405
405
|
updatedContentPlugins: {},
|
|
406
406
|
assetMap: {},
|
|
407
|
+
newAssetIds: [],
|
|
407
408
|
newTagIds: [],
|
|
408
409
|
contentJson: { course: {} },
|
|
409
410
|
idMap: {},
|
|
@@ -438,12 +439,29 @@ describe('AdaptFrameworkImport', () => {
|
|
|
438
439
|
assetMap: {
|
|
439
440
|
'course/en/assets/logo.png': 'a1',
|
|
440
441
|
'course/en/assets/bg.jpg': 'a2'
|
|
441
|
-
}
|
|
442
|
+
},
|
|
443
|
+
newAssetIds: ['a1', 'a2']
|
|
442
444
|
})
|
|
443
445
|
await rollback.call(ctx)
|
|
444
446
|
assert.deepEqual(deleted.sort(), ['a1', 'a2'])
|
|
445
447
|
})
|
|
446
448
|
|
|
449
|
+
it('should not delete de-duplicated assets that point to pre-existing records', async () => {
|
|
450
|
+
const deleted = []
|
|
451
|
+
const ctx = makeRollbackCtx({
|
|
452
|
+
assets: {
|
|
453
|
+
delete: async ({ _id }) => deleted.push(_id)
|
|
454
|
+
},
|
|
455
|
+
assetMap: {
|
|
456
|
+
'course/en/assets/new.png': 'a1',
|
|
457
|
+
'course/en/assets/existing.png': 'a2-existing'
|
|
458
|
+
},
|
|
459
|
+
newAssetIds: ['a1']
|
|
460
|
+
})
|
|
461
|
+
await rollback.call(ctx)
|
|
462
|
+
assert.deepEqual(deleted, ['a1'])
|
|
463
|
+
})
|
|
464
|
+
|
|
447
465
|
it('should delete course content on rollback', async () => {
|
|
448
466
|
const contentDeleted = []
|
|
449
467
|
const ctx = makeRollbackCtx({
|
|
@@ -499,7 +517,8 @@ describe('AdaptFrameworkImport', () => {
|
|
|
499
517
|
'path/a.png': 'a1',
|
|
500
518
|
'path/b.png': 'a2',
|
|
501
519
|
'path/c.png': 'a3'
|
|
502
|
-
}
|
|
520
|
+
},
|
|
521
|
+
newAssetIds: ['a1', 'a2', 'a3']
|
|
503
522
|
})
|
|
504
523
|
await rollback.call(ctx)
|
|
505
524
|
assert.deepEqual(deleted.sort(), ['a2', 'a3'])
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { applyContentAccessFilter } from '../lib/utils/applyContentAccessFilter.js'
|
|
4
|
+
|
|
5
|
+
describe('applyContentAccessFilter()', () => {
|
|
6
|
+
it('should be a no-op when userId is falsy', () => {
|
|
7
|
+
const query = { _type: 'course' }
|
|
8
|
+
applyContentAccessFilter(query, undefined)
|
|
9
|
+
assert.deepEqual(query, { _type: 'course' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should add a $or clause covering creator, public, and per-user sharing', () => {
|
|
13
|
+
const query = { _type: 'course' }
|
|
14
|
+
applyContentAccessFilter(query, 'user1')
|
|
15
|
+
assert.deepEqual(query.$or, [
|
|
16
|
+
{ createdBy: 'user1' },
|
|
17
|
+
{ _isShared: true },
|
|
18
|
+
{ _shareWithUsers: 'user1' }
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should preserve an existing $or by lifting both into $and', () => {
|
|
23
|
+
const query = { $or: [{ title: 'foo' }] }
|
|
24
|
+
applyContentAccessFilter(query, 'user1')
|
|
25
|
+
assert.equal(query.$or, undefined)
|
|
26
|
+
assert.deepEqual(query.$and, [
|
|
27
|
+
{ $or: [{ title: 'foo' }] },
|
|
28
|
+
{
|
|
29
|
+
$or: [
|
|
30
|
+
{ createdBy: 'user1' },
|
|
31
|
+
{ _isShared: true },
|
|
32
|
+
{ _shareWithUsers: 'user1' }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should append to an existing $and rather than clobber it', () => {
|
|
39
|
+
const query = { $or: [{ title: 'foo' }], $and: [{ flag: true }] }
|
|
40
|
+
applyContentAccessFilter(query, 'user1')
|
|
41
|
+
assert.equal(query.$or, undefined)
|
|
42
|
+
assert.equal(query.$and.length, 3)
|
|
43
|
+
assert.deepEqual(query.$and[0], { flag: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should leave other top-level keys intact', () => {
|
|
47
|
+
const query = { _type: 'course', title: { $regex: 'foo' } }
|
|
48
|
+
applyContentAccessFilter(query, 'user1')
|
|
49
|
+
assert.equal(query._type, 'course')
|
|
50
|
+
assert.deepEqual(query.title, { $regex: 'foo' })
|
|
51
|
+
})
|
|
52
|
+
})
|