import AbstractApiModule from 'adapt-authoring-api'
import AdaptCli from 'adapt-cli'
import apidefs from './apidefs.js'
import fs from 'fs/promises'
import { glob } from 'glob'
import path from 'path'
import semver from 'semver'
/**
* Abstract module which handles framework plugins
* @memberof contentplugin
* @extends {AbstractApiModule}
*/
class ContentPluginModule extends AbstractApiModule {
/**
* Common arguments to be passed to the CLI
* @return {Object}
*/
get cliArgs () {
const debugLog = (...args) => this.app.logger.log('debug', 'adapt-cli', ...args)
return {
cwd: this.framework.path,
// a wrapper for the LoggerModule to be used with the CLI's logging
logger: { log: debugLog, logProgress: debugLog }
}
}
/** @override */
async setValues () {
/** @ignore */ this.collectionName = 'contentplugins'
/** @ignore */ this.root = 'contentplugins'
/** @ignore */ this.schemaName = 'contentplugin'
/**
* Reference to all content plugin schemas, grouped by plugin
* @type {Object}
*/
this.pluginSchemas = {}
/**
* A list of newly installed plugins
* @type {Array}
*/
this.newPlugins = []
const middleware = await this.app.waitForModule('middleware')
this.useDefaultRouteConfig()
// remove unnecessary routes
delete this.routes.find(r => r.route === '/').handlers.post
delete this.routes.find(r => r.route === '/:_id').handlers.put
// extra routes
this.routes.push({
route: '/install',
handlers: {
post: [
middleware.fileUploadParser(middleware.zipTypes, { unzip: true }),
this.installHandler.bind(this)
]
},
permissions: { post: ['install:contentplugin'] },
validate: false,
meta: apidefs.install
},
{
route: '/:_id/update',
handlers: { post: this.updateHandler.bind(this) },
permissions: { post: ['update:contentplugin'] },
meta: apidefs.update
},
{
route: '/:_id/uses',
handlers: { get: this.usesHandler.bind(this) },
permissions: { get: ['read:contentplugin'] },
meta: apidefs.uses
})
}
/** @override */
async init () {
await super.init()
// env var used by the CLI
if (!process.env.ADAPT_ALLOW_PRERELEASE) {
process.env.ADAPT_ALLOW_PRERELEASE = 'true'
}
const [framework, mongodb] = await this.app.waitForModule('adaptframework', 'mongodb')
await mongodb.setIndex(this.collectionName, 'name', { unique: true })
await mongodb.setIndex(this.collectionName, 'displayName', { unique: true })
await mongodb.setIndex(this.collectionName, 'type')
/**
* Cached module instance for easy access
* @type {AdaptFrameworkModule}
*/
this.framework = framework
try {
await this.initPlugins()
} catch (e) {
this.log('error', e)
}
this.framework.postInstallHook.tap(this.initPlugins.bind(this))
this.framework.postUpdateHook.tap(this.initPlugins.bind(this))
}
/** @override */
async find (query = {}, options = {}, mongoOptions = {}) {
const includeUpdateInfo = options.includeUpdateInfo === true || options.includeUpdateInfo === 'true'
// special option that's passed via query
delete query.includeUpdateInfo
const results = await super.find(query, options, mongoOptions)
if (includeUpdateInfo) {
const updateInfo = await AdaptCli.getPluginUpdateInfos({
...this.cliArgs,
plugins: results.map(r => r.name)
})
results.forEach(r => {
const info = updateInfo.find(i => i.name === r.name)
if (info) {
r.canBeUpdated = info.canBeUpdated
r.latestCompatibleVersion = info.latestCompatibleSourceVersion
}
})
}
return results
}
/**
* Inserts a new document or performs an update if matching data already exists
* @param {Object} data Data to be sent to the DB
* @param {Object} options Options to pass to the DB function
* @returns {Promise} Resolves with the returned data
*/
async insertOrUpdate (data, options = { useDefaults: true }) {
return !(await this.find({ name: data.name })).length
? this.insert(data, options)
: this.update({ name: data.name }, data, options)
}
/** @override */
async delete (query, options, mongoOptions) {
const _id = query._id
const courses = await this.getPluginUses(_id)
if (courses.length) {
throw this.app.errors.CONTENTPLUGIN_IN_USE.setData({ courses })
}
const [pluginData] = await this.find({ _id })
// unregister any schemas
const jsonschema = await this.app.waitForModule('jsonschema')
const schemaPaths = await glob(`src/*/${pluginData.name}/schema/*.schema.json`, { cwd: this.framework.path, absolute: true })
schemaPaths.forEach(s => jsonschema.deregisterSchema(s))
await AdaptCli.uninstallPlugins({ ...this.cliArgs, plugins: [pluginData.name] })
this.log('info', `successfully removed plugin ${pluginData.name}`)
return super.delete(query, options, mongoOptions)
}
/**
* Initialises all framework plugins, from adapt.json and local cache
* @return {Promise}
*/
async initPlugins () {
const dbPlugins = await this.find()
const installedPlugins = await this.framework.getInstalledPlugins()
if (installedPlugins.length) { // process existing schemas
await this.processPluginSchemas()
}
if (!dbPlugins.length) { // no plugins in the DB, start afresh
const manifestPlugins = await this.framework.getManifestPlugins()
return this.installPlugins(manifestPlugins)
}
// throw error on mismatch between DB data + the actual installed version
const versionMismatches = []
dbPlugins.forEach(dbP => {
const fwP = installedPlugins.find(fwP => dbP.name === fwP.name)
if (!fwP) return
if (fwP._projectInfo.version !== dbP.version) {
versionMismatches.push({ plugin: dbP, installedVersion: fwP._projectInfo.version })
}
})
if (versionMismatches.length) {
const data = versionMismatches.map(({ plugin, installedVersion }) => {
const location = plugin.isLocalInstall ? path.join(this.getConfig('pluginDir'), plugin.name) : plugin.name
const dbVersion = plugin.version
return [location, installedVersion, dbVersion].join(',')
})
this.log('error', this.app.errors.CONTENTPLUGIN_VERSION_MISMATCH
.setData({ registered: data }))
}
// attempt to reinstall missing plugins
const missingPlugins = dbPlugins
.filter(dbP => !installedPlugins.find(fwP => dbP.name === fwP.name))
.map(p => `${p.name}@${p.isLocalInstall ? path.join(this.getConfig('pluginDir'), p.name) : p.version}`)
if (missingPlugins.length) { // use CLI directly, as plugins already exist in the DB
await AdaptCli.installPlugins({ ...this.cliArgs, plugins: missingPlugins })
}
}
/**
* Loads and processes all installed content plugin schemas
* @param {Array} pluginInfo Plugin info data
* @return {Promise}
*/
async processPluginSchemas (pluginInfo) {
if (!pluginInfo) {
pluginInfo = await AdaptCli.getPluginUpdateInfos(this.cliArgs)
}
const jsonschema = await this.app.waitForModule('jsonschema')
return Promise.all(pluginInfo.map(async plugin => {
const name = plugin.name
const oldSchemaPaths = this.pluginSchemas[name]
if (oldSchemaPaths) {
Object.values(oldSchemaPaths).forEach(s => jsonschema.deregisterSchema(s))
delete this.pluginSchemas[name]
}
const schemaPaths = await plugin.getSchemaPaths()
return Promise.all(schemaPaths.map(async schemaPath => {
const schema = JSON.parse(await fs.readFile(schemaPath))
const source = schema?.$patch?.source?.$ref
if (source) {
if (!this.pluginSchemas[name]) this.pluginSchemas[name] = []
if (this.pluginSchemas[name].includes(schema.$anchor)) jsonschema.deregisterSchema(this.pluginSchemas[name][source])
this.pluginSchemas[name].push(schema.$anchor)
}
return jsonschema.registerSchema(schemaPath, { replace: true })
}))
}))
}
/**
* Returns whether a schema is registered by a plugin
* @param {String} schemaName Name of the schema to check
* @return {Boolean}
*/
isPluginSchema (schemaName) {
for (const p in this.pluginSchemas) {
if (this.pluginSchemas[p].includes(schemaName)) return true
}
}
/**
* Returns all schemas registered by a plugin
* @param {String} pluginName Plugin name
* @return {Array} List of the plugin's registered schemas
*/
getPluginSchemas (pluginName) {
return this.pluginSchemas[pluginName] ?? []
}
/**
* Retrieves the courses in which a plugin is used
* @param {String} pluginId Plugin _id
* @returns {Promise} Resolves with an array of course data
*/
async getPluginUses (pluginId) {
const [{ name }] = await this.find({ _id: pluginId })
const [content, db] = await this.app.waitForModule('content', 'mongodb')
return (db.getCollection(content.collectionName).aggregate([
{ $match: { _type: 'config', _enabledPlugins: name } },
{ $lookup: { from: 'content', localField: '_courseId', foreignField: '_id', as: 'course' } },
{ $unwind: '$course' },
{ $replaceRoot: { newRoot: '$course' } },
{ $lookup: { from: 'users', localField: 'createdBy', foreignField: '_id', as: 'createdBy' } },
{ $project: { title: 1, createdBy: { $map: { input: '$createdBy', as: 'user', in: '$$user.email' } } } },
{ $unwind: '$createdBy' }
])).toArray()
}
/**
* Installs new plugins
* @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath]
* @param {Object} options
* @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
* @param {Boolean} options.strict Whether the function should fail on error
*/
async installPlugins (plugins, options = { strict: false, force: false }) {
const errors = []
const installed = []
await Promise.all(plugins.map(async ([name, versionOrPath]) => {
try {
const data = await this.installPlugin(name, versionOrPath, options)
installed.push(data)
this.log('info', 'PLUGIN_INSTALL', `${data.name}@${data.version}`)
} catch (e) {
this.log('warn', 'PLUGIN_INSTALL_FAIL', name, e?.data?.error ?? e)
errors.push(e)
}
}))
if (errors.length && options.strict) {
throw this.app.errors.CONTENTPLUGIN_INSTALL_FAILED
.setData({ errors })
}
return installed
}
/**
* Installs a single plugin. Note: this function is called by installPlugins and should not be called directly.
* @param {String} pluginName Name of the plugin to install
* @param {String} versionOrPath The semver-formatted version, or the path to the plugin source
* @param {Object} options
* @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
* @param {Boolean} options.strict Whether the function should fail on error
* @returns Resolves with plugin DB data
*/
async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) {
const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles(pluginName, versionOrPath)
const [existing] = await this.find({ name })
if (existing && !options.force && semver.lte(version, existing.version)) {
throw this.app.errors.CONTENTPLUGIN_ALREADY_EXISTS
.setData({ name: existing.name, version: existing.version })
}
const [data] = await AdaptCli.installPlugins({
...this.cliArgs,
plugins: [`${name}@${sourcePath ?? version}`]
})
const info = await this.insertOrUpdate({
...(await data.getInfo()),
type: await data.getType(),
isLocalInstall
})
if (!data.isInstallSuccessful) {
throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED
.setData({ name })
}
if (!info.targetAttribute) {
throw this.app.errors.CONTENTPLUGIN_ATTR_MISSING
.setData({ name })
}
await this.processPluginSchemas([data])
return info
}
/**
* Ensures local plugin source files are stored in the correct location and structured in an expected way
* @param {String} name Name of the plugin to install
* @param {String} sourcePath The path to the plugin source files
* @returns Resolves with package data
*/
async processPluginFiles (name, sourcePath) {
if (sourcePath === path.basename(sourcePath)) { // no local files
return { name, version: sourcePath, isLocalInstall: false }
}
const contents = await fs.readdir(sourcePath)
if (contents.length === 1) { // deal with a nested root folder
sourcePath = path.join(sourcePath, contents[0])
}
let pkg
try {
// load package data
try {
pkg = JSON.parse(await fs.readFile(path.join(sourcePath, 'package.json')))
} catch (e) { // if no package.json, try to load bower.json
pkg = JSON.parse(await fs.readFile(path.join(sourcePath, 'bower.json')))
}
pkg.sourcePath = path.join(this.getConfig('pluginDir'), pkg.name)
pkg.isLocalInstall = true
} catch (e) {
throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
}
// move the files into the persistent location
await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
await fs.rm(sourcePath, { recursive: true })
return pkg
}
/**
* Updates a single plugin
* @param {String} _id The _id for the plugin to update
* @return Resolves with update data
*/
async updatePlugin (_id) {
const [{ name }] = await this.find({ _id })
const [pluginData] = await AdaptCli.updatePlugins({ ...this.cliArgs, plugins: [name] })
const p = await this.update({ name }, pluginData._sourceInfo)
this.log('info', `successfully updated plugin ${p.name}@${p.version}`)
return p
}
/** @override */
serveSchema () {
return async (req, res, next) => {
try {
const plugin = await this.get({ name: req.apiData.query.type }) || {}
const schema = await this.getSchema(plugin.schemaName)
if (!schema) {
return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
}
res.type('application/schema+json').json(schema)
} catch (e) {
return next(e)
}
}
}
/**
* Express request handler for installing a plugin
* @param {external:ExpressRequest} req
* @param {external:ExpressResponse} res
* @param {Function} next
*/
async installHandler (req, res, next) {
try {
const [pluginData] = await this.installPlugins([
[
req.body.name,
req?.fileUpload?.files?.file?.[0]?.filepath ?? req.body.version
]
], {
force: req.body.force === 'true' || req.body.force === true,
strict: true
})
res.status(this.mapStatusCode('post')).send(pluginData)
} catch (error) {
if (error.code === this.app.errors.CONTENTPLUGIN_INSTALL_FAILED.code) {
error.data.errors = error.data.errors.map(req.translate)
}
res.sendError(error)
}
}
/**
* Express request handler for updating a plugin
* @param {external:ExpressRequest} req
* @param {external:ExpressResponse} res
* @param {Function} next
*/
async updateHandler (req, res, next) {
try {
const pluginData = await this.updatePlugin(req.params._id)
res.status(this.mapStatusCode('put')).send(pluginData)
} catch (error) {
return next(error)
}
}
/**
* Express request handler for retrieving uses of a single plugin
* @param {external:ExpressRequest} req
* @param {external:ExpressResponse} res
* @param {Function} next
*/
async usesHandler (req, res, next) {
try {
const data = await this.getPluginUses(req.params._id)
res.status(this.mapStatusCode('put')).send(data)
} catch (error) {
return next(error)
}
}
}
export default ContentPluginModule