import { AbstractModule, Hook } from 'adapt-authoring-core'
import AdaptCli from 'adapt-cli'
import AdaptFrameworkBuild from './AdaptFrameworkBuild.js'
import AdaptFrameworkImport from './AdaptFrameworkImport.js'
import ApiDefs from './apidefs.js'
import fs from 'fs-extra'
import FWUtils from './AdaptFrameworkUtils.js'
import path from 'path'
import semver from 'semver'
import { unzip } from 'zipper'
/**
* Module to handle the interface with the Adapt framework
* @memberof adaptframework
* @extends {AbstractModule}
*/
class AdaptFrameworkModule extends AbstractModule {
/** @override */
async init () {
/**
* Location of the local Adapt framework files
* @type {String}
*/
this.path = this.getConfig('frameworkDir')
/**
* Invoked after a framework install
* @type {Hook}
*/
this.postInstallHook = new Hook()
/**
* Invoked after a framework update
* @type {Hook}
*/
this.postUpdateHook = new Hook()
/**
* Invoked prior to a course being built. The AdaptFrameworkBuild instance is passed to any observers.
* @type {Hook}
*/
this.preBuildHook = new Hook({ mutable: true })
/**
* Invoked after a course has been built. The AdaptFrameworkBuild instance is passed to any observers.
* @type {Hook}
*/
this.postBuildHook = new Hook({ mutable: true })
/**
* Content migration functions to be run on import
* @type {Array}
*/
this.contentMigrations = []
await this.installFramework()
if (this.app.args['update-framework'] === true) {
await this.updateFramework()
}
await Promise.all([this.loadSchemas(), this.initRoutes()])
const content = await this.app.waitForModule('content')
content.accessCheckHook.tap(this.checkContentAccess.bind(this))
this.logStatus()
}
/**
* Semver formatted version number of the local framework copy
* @type {String}
*/
get version () {
return AdaptCli.getCurrentFrameworkVersion({ cwd: this.path })
}
/**
* Installs a local copy of the Adapt framework
* @return {Promise}
*/
async installFramework (version, force = false) {
try {
const modsPath = path.resolve(this.path, '..', 'node_modules')
try {
await fs.stat(modsPath)
await fs.readJson(path.resolve(this.path, 'package.json'))
if (!force) {
return
}
await fs.remove(this.path)
} catch (e) {
// if src and node_modules are missing, install required
}
await AdaptCli.installFramework({
version,
repository: this.getConfig('frameworkRepository'),
cwd: this.path
})
// move node_modules into place
try {
await fs.remove(modsPath)
} catch (e) {}
// move node_modules so it can be shared with all builds
await fs.move(path.join(this.path, 'node_modules'), modsPath)
} catch (e) {
this.log('error', `failed to install framework, ${e.message}`)
throw this.app.errors.FW_INSTALL_FAILED
}
this.postInstallHook.invoke()
}
/**
* Updates the local copy of the Adapt framework
* @return {Promise}
*/
async getLatestVersion () {
try {
return semver.clean(await AdaptCli.getLatestFrameworkVersion({ repository: this.getConfig('frameworkRepository') }))
} catch (e) {
this.log('error', `failed to retrieve framework update data, ${e.message}`)
throw e
}
}
/**
* Retrieves the plugins listed in the framework manifest, but not necessarily installed
* @return {Promise}
*/
async getManifestPlugins () {
const manifest = await fs.readJson(path.resolve(this.path, 'adapt.json'))
return Object.entries(manifest.dependencies)
}
/**
* Retrieves the locally installed plugins
* @return {Promise}
*/
async getInstalledPlugins () {
return AdaptCli.getInstalledPlugins({ cwd: this.path })
}
/**
* Updates the local copy of the Adapt framework
* @param {string} version The version to update to
* @return {Promise}
*/
async updateFramework (version) {
try {
await AdaptCli.updateFramework({
cwd: this.path,
repository: this.getConfig('frameworkRepository'),
version
})
} catch (e) {
this.log('error', `failed to update framework, ${e.message}`)
throw this.app.errors.FW_UPDATE_FAILED
}
this.postUpdateHook.invoke()
}
/**
* Logs relevant framework status messages
*/
async logStatus () {
const current = this.version
const latest = await AdaptCli.getLatestFrameworkVersion({ repository: this.getConfig('frameworkRepository') })
this.log('info', `local adapt_framework v${current} installed`)
if (semver.lt(current, latest)) {
this.log('info', `a newer version of the adapt_framework is available (${latest}), pass the --update-framework flag to update`)
}
}
/**
* Loads schemas from the local copy of the Adapt framework and registers them with the app
* @return {Promise}
*/
async loadSchemas () {
const jsonschema = await this.app.waitForModule('jsonschema')
const schemas = (await AdaptCli.getSchemaPaths({ cwd: this.path })).filter(s => s.includes('/core/'))
await Promise.all(schemas.map(s => jsonschema.registerSchema(s)))
}
/**
* Checks whether the request user should be given access to the content they're requesting
* @param {external:ExpressRequest} req
* @param {Object} data
* @return {Promise} Resolves with boolean
*/
async checkContentAccess (req, data) {
const content = await this.app.waitForModule('content')
let course
if (data._type === 'course') {
course = data
} else {
[course] = await content.find({ _id: data._courseId || (await content.find(data))._courseId })
}
if (!course) {
return
}
const shareWithUsers = course?._shareWithUsers.map(id => id.toString()) ?? []
const userId = req.auth.user._id.toString()
return course._isShared || shareWithUsers.includes(userId)
}
/**
* Initialises the module routing
* @return {Promise}
*/
async initRoutes () {
const [auth, server] = await this.app.waitForModule('auth', 'server')
/**
* Router for handling all non-API calls
* @type {Router}
*/
this.rootRouter = server.root.createChildRouter('adapt')
this.rootRouter.addRoute({
route: '/preview/:id/*',
handlers: {
get: (req, res, next) => { // fail silently
FWUtils.getHandler(req, res, e => e ? res.status(e.statusCode || 500).end() : next())
}
}
})
/**
* Router for handling all API calls
* @type {Router}
*/
this.apiRouter = server.api.createChildRouter('adapt')
this.apiRouter.addRoute(
{
route: '/preview/:id',
handlers: { post: FWUtils.postHandler },
meta: ApiDefs.preview
},
{
route: '/publish/:id',
handlers: { post: FWUtils.postHandler, get: FWUtils.getHandler },
meta: ApiDefs.publish
},
{
route: '/import',
handlers: { post: [FWUtils.importHandler] },
meta: ApiDefs.import
},
{
route: '/export/:id',
handlers: { post: FWUtils.postHandler, get: FWUtils.getHandler },
meta: ApiDefs.export
}
)
auth.secureRoute(`${this.apiRouter.path}/preview/:id`, 'post', ['preview:adapt'])
auth.secureRoute(`${this.apiRouter.path}/publish/:id`, 'get', ['publish:adapt'])
auth.secureRoute(`${this.apiRouter.path}/publish/:id`, 'post', ['publish:adapt'])
auth.secureRoute(`${this.apiRouter.path}/import`, 'post', ['import:adapt'])
auth.secureRoute(`${this.apiRouter.path}/export/:id`, 'get', ['export:adapt'])
auth.secureRoute(`${this.apiRouter.path}/export/:id`, 'post', ['export:adapt'])
auth.secureRoute(`${this.apiRouter.path}/update`, 'post', ['update:adapt'])
if (this.getConfig('enableUpdateApi')) {
this.apiRouter.addRoute({
route: '/update',
handlers: { post: FWUtils.postUpdateHandler, get: FWUtils.getUpdateHandler },
meta: ApiDefs.update
})
auth.secureRoute(`${this.apiRouter.path}/update`, 'get', ['update:adapt'])
}
}
registerImportContentMigration (migration) {
if (typeof migration !== 'function') {
return this.log('warn', `Cannot register content migration, unexpected type (${typeof migration})`)
}
this.contentMigrations.push(migration)
}
/**
* Builds a single Adapt framework course
* @param {AdaptFrameworkBuildOptions} options
* @return {AdaptFrameworkBuild}
*/
async buildCourse (options) {
return AdaptFrameworkBuild.run(options)
}
/**
* Imports a single Adapt framework course
* @param {String} importPath Path to the course import
* @param {String} userId _id of the new owner of the imported course
* @return {AdaptFrameworkImportSummary}
*/
async importCourse (importPath, userId) {
let unzipPath = importPath
if (importPath.endsWith('.zip')) {
unzipPath = `${importPath}_unzip`
await unzip(importPath, unzipPath, { removeSource: true })
}
const importer = await AdaptFrameworkImport.run({ unzipPath, userId })
return await FWUtils.getImportSummary(importer)
}
}
export default AdaptFrameworkModule