Adapt authoring tool UI documentation

v1.0.0-rc.4

adapt-authoring-contentplugin/lib/ContentPluginModule.js

  1. import AbstractApiModule from 'adapt-authoring-api'
  2. import apidefs from './apidefs.js'
  3. import fs from 'fs/promises'
  4. import { glob } from 'glob'
  5. import path from 'path'
  6. import semver from 'semver'
  7. /**
  8. * Abstract module which handles framework plugins
  9. * @memberof contentplugin
  10. * @extends {AbstractApiModule}
  11. */
  12. class ContentPluginModule extends AbstractApiModule {
  13. /** @override */
  14. async setValues () {
  15. /** @ignore */ this.collectionName = 'contentplugins'
  16. /** @ignore */ this.root = 'contentplugins'
  17. /** @ignore */ this.schemaName = 'contentplugin'
  18. /**
  19. * Reference to all content plugin schemas, grouped by plugin
  20. * @type {Object}
  21. */
  22. this.pluginSchemas = {}
  23. /**
  24. * A list of newly installed plugins
  25. * @type {Array}
  26. */
  27. this.newPlugins = []
  28. const middleware = await this.app.waitForModule('middleware')
  29. this.useDefaultRouteConfig()
  30. // remove unnecessary routes
  31. delete this.routes.find(r => r.route === '/').handlers.post
  32. delete this.routes.find(r => r.route === '/:_id').handlers.put
  33. // extra routes
  34. this.routes.push({
  35. route: '/install',
  36. handlers: {
  37. post: [
  38. middleware.fileUploadParser(middleware.zipTypes, { unzip: true }),
  39. this.installHandler.bind(this)
  40. ]
  41. },
  42. permissions: { post: ['install:contentplugin'] },
  43. validate: false,
  44. meta: apidefs.install
  45. },
  46. {
  47. route: '/:_id/update',
  48. handlers: { post: this.updateHandler.bind(this) },
  49. permissions: { post: ['update:contentplugin'] },
  50. meta: apidefs.update
  51. },
  52. {
  53. route: '/:_id/uses',
  54. handlers: { get: this.usesHandler.bind(this) },
  55. permissions: { get: ['read:contentplugin'] },
  56. meta: apidefs.uses
  57. })
  58. }
  59. /** @override */
  60. async init () {
  61. await super.init()
  62. // env var used by the CLI
  63. if (!process.env.ADAPT_ALLOW_PRERELEASE) {
  64. process.env.ADAPT_ALLOW_PRERELEASE = 'true'
  65. }
  66. const [framework, mongodb] = await this.app.waitForModule('adaptframework', 'mongodb')
  67. await mongodb.setIndex(this.collectionName, 'name', { unique: true })
  68. await mongodb.setIndex(this.collectionName, 'displayName', { unique: true })
  69. await mongodb.setIndex(this.collectionName, 'type')
  70. /**
  71. * Cached module instance for easy access
  72. * @type {AdaptFrameworkModule}
  73. */
  74. this.framework = framework
  75. try {
  76. await this.initPlugins()
  77. } catch (e) {
  78. this.log('error', e)
  79. }
  80. this.framework.postInstallHook.tap(this.syncPluginData.bind(this))
  81. this.framework.postUpdateHook.tap(this.syncPluginData.bind(this))
  82. }
  83. /** @override */
  84. async find (query = {}, options = {}, mongoOptions = {}) {
  85. const includeUpdateInfo = options.includeUpdateInfo === true || options.includeUpdateInfo === 'true'
  86. // special option that's passed via query
  87. delete query.includeUpdateInfo
  88. const results = await super.find(query, options, mongoOptions)
  89. if (includeUpdateInfo) {
  90. const updateInfo = await this.framework.runCliCommand('getPluginUpdateInfos', { plugins: results.map(r => r.name) })
  91. results.forEach(r => {
  92. const info = updateInfo.find(i => i.name === r.name)
  93. if (info) {
  94. r.canBeUpdated = info.canBeUpdated
  95. r.latestCompatibleVersion = info.latestCompatibleSourceVersion
  96. r._cliData = info
  97. }
  98. })
  99. }
  100. return results
  101. }
  102. async readJson(filepath) {
  103. return JSON.parse(await fs.readFile(filepath))
  104. }
  105. /**
  106. * Inserts a new document or performs an update if matching data already exists
  107. * @param {Object} data Data to be sent to the DB
  108. * @param {Object} options Options to pass to the DB function
  109. * @returns {Promise} Resolves with the returned data
  110. */
  111. async insertOrUpdate (data, options = { useDefaults: true }) {
  112. return !(await this.find({ name: data.name })).length
  113. ? this.insert(data, options)
  114. : this.update({ name: data.name }, data, options)
  115. }
  116. /** @override */
  117. async delete (query, options, mongoOptions) {
  118. const _id = query._id
  119. const courses = await this.getPluginUses(_id)
  120. if (courses.length) {
  121. throw this.app.errors.CONTENTPLUGIN_IN_USE.setData({ courses })
  122. }
  123. const [pluginData] = await this.find({ _id })
  124. // unregister any schemas
  125. const jsonschema = await this.app.waitForModule('jsonschema')
  126. const schemaPaths = await glob(`src/*/${pluginData.name}/schema/*.schema.json`, { cwd: this.framework.path, absolute: true })
  127. schemaPaths.forEach(s => jsonschema.deregisterSchema(s))
  128. await this.framework.runCliCommand('uninstallPlugins', { plugins: [pluginData.name] })
  129. this.log('info', `successfully removed plugin ${pluginData.name}`)
  130. return super.delete(query, options, mongoOptions)
  131. }
  132. /**
  133. * Initialises all framework plugins, from adapt.json and local cache
  134. * @return {Promise}
  135. */
  136. async initPlugins () {
  137. await this.syncPluginData()
  138. const missing = await this.getMissingPlugins()
  139. if (missing.length) { // note we're using CLI directly, as plugins already exist in the DB
  140. this.log('debug', 'MISSING', missing)
  141. await this.framework.runCliCommand('installPlugins', { plugins: missing })
  142. }
  143. await this.processPluginSchemas()
  144. }
  145. /**
  146. * Makes sure the database plugin data is in sync with the currently installed framework plugins
  147. */
  148. async syncPluginData() {
  149. const dbInfo = (await this.find()).reduce((memo, info) => Object.assign(memo, { [info.name]: info }), {})
  150. for (const i of await this.framework.runCliCommand('getPluginUpdateInfos')) {
  151. if(dbInfo[i.name]?.version !== i.matchedVersion) {
  152. this.log('debug', 'SYNC', i.name, 'local:', dbInfo[i.name]?.version, 'fw:', i.matchedVersion)
  153. await this.insertOrUpdate({ ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource })
  154. }
  155. }
  156. }
  157. /**
  158. * Returns a list of plugins defined in the database but not installed in the framework
  159. * @return {Array} List of plugin names mapped to version/dir
  160. */
  161. async getMissingPlugins() {
  162. const dbPlugins = await this.find()
  163. const fwPlugins = await this.framework.getInstalledPlugins()
  164. return dbPlugins
  165. .filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
  166. .map(p => `${p.name}@${p.isLocalInstall ? path.join(this.getConfig('pluginDir'), p.name) : p.version}`)
  167. }
  168. /**
  169. * Loads and processes all installed content plugin schemas
  170. * @param {Array} pluginInfo Plugin info data
  171. * @return {Promise}
  172. */
  173. async processPluginSchemas (pluginInfo) {
  174. if (!pluginInfo) {
  175. pluginInfo = await this.framework.runCliCommand('getPluginUpdateInfos')
  176. }
  177. const jsonschema = await this.app.waitForModule('jsonschema')
  178. return Promise.all(pluginInfo.map(async plugin => {
  179. const name = plugin.name
  180. const oldSchemaPaths = this.pluginSchemas[name]
  181. if (oldSchemaPaths) {
  182. Object.values(oldSchemaPaths).forEach(s => jsonschema.deregisterSchema(s))
  183. delete this.pluginSchemas[name]
  184. }
  185. const schemaPaths = await plugin.getSchemaPaths()
  186. return Promise.all(schemaPaths.map(async schemaPath => {
  187. const schema = await this.readJson(schemaPath)
  188. const source = schema?.$patch?.source?.$ref
  189. if (source) {
  190. if (!this.pluginSchemas[name]) this.pluginSchemas[name] = []
  191. if (this.pluginSchemas[name].includes(schema.$anchor)) jsonschema.deregisterSchema(this.pluginSchemas[name][source])
  192. this.pluginSchemas[name].push(schema.$anchor)
  193. }
  194. return jsonschema.registerSchema(schemaPath, { replace: true })
  195. }))
  196. }))
  197. }
  198. /**
  199. * Returns whether a schema is registered by a plugin
  200. * @param {String} schemaName Name of the schema to check
  201. * @return {Boolean}
  202. */
  203. isPluginSchema (schemaName) {
  204. for (const p in this.pluginSchemas) {
  205. if (this.pluginSchemas[p].includes(schemaName)) return true
  206. }
  207. }
  208. /**
  209. * Returns all schemas registered by a plugin
  210. * @param {String} pluginName Plugin name
  211. * @return {Array} List of the plugin's registered schemas
  212. */
  213. getPluginSchemas (pluginName) {
  214. return this.pluginSchemas[pluginName] ?? []
  215. }
  216. /**
  217. * Retrieves the courses in which a plugin is used
  218. * @param {String} pluginId Plugin _id
  219. * @returns {Promise} Resolves with an array of course data
  220. */
  221. async getPluginUses (pluginId) {
  222. const [{ name }] = await this.find({ _id: pluginId })
  223. const [content, db] = await this.app.waitForModule('content', 'mongodb')
  224. return (db.getCollection(content.collectionName).aggregate([
  225. { $match: { _type: 'config', _enabledPlugins: name } },
  226. { $lookup: { from: 'content', localField: '_courseId', foreignField: '_id', as: 'course' } },
  227. { $unwind: '$course' },
  228. { $replaceRoot: { newRoot: '$course' } },
  229. { $lookup: { from: 'users', localField: 'createdBy', foreignField: '_id', as: 'createdBy' } },
  230. { $project: { title: 1, createdBy: { $map: { input: '$createdBy', as: 'user', in: '$$user.email' } } } },
  231. { $unwind: '$createdBy' }
  232. ])).toArray()
  233. }
  234. /**
  235. * Installs new plugins
  236. * @param {Array[]} plugins 2D array of strings in the format [pluginName, versionOrPath]
  237. * @param {Object} options
  238. * @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
  239. * @param {Boolean} options.strict Whether the function should fail on error
  240. */
  241. async installPlugins (plugins, options = { strict: false, force: false }) {
  242. const errors = []
  243. const installed = []
  244. await Promise.all(plugins.map(async ([name, versionOrPath]) => {
  245. try {
  246. const data = await this.installPlugin(name, versionOrPath, options)
  247. installed.push(data)
  248. this.log('info', 'PLUGIN_INSTALL', `${data.name}@${data.version}`)
  249. } catch (e) {
  250. this.log('warn', 'PLUGIN_INSTALL_FAIL', name, e?.data?.error ?? e)
  251. errors.push(e)
  252. }
  253. }))
  254. if (errors.length && options.strict) {
  255. throw this.app.errors.CONTENTPLUGIN_INSTALL_FAILED
  256. .setData({ errors })
  257. }
  258. return installed
  259. }
  260. /**
  261. * Installs a single plugin. Note: this function is called by installPlugins and should not be called directly.
  262. * @param {String} pluginName Name of the plugin to install
  263. * @param {String} versionOrPath The semver-formatted version, or the path to the plugin source
  264. * @param {Object} options
  265. * @param {Boolean} options.force Whether the plugin should be 'force' installed if version is lower than the existing
  266. * @param {Boolean} options.strict Whether the function should fail on error
  267. * @returns Resolves with plugin DB data
  268. */
  269. async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) {
  270. const [pluginData] = await this.find({ name: pluginName }, { includeUpdateInfo: true })
  271. const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })
  272. if (pluginData) {
  273. if (!options.force && semver.lte(version, pluginData.version)) {
  274. throw this.app.errors.CONTENTPLUGIN_ALREADY_EXISTS
  275. .setData({ name: pluginData.name, version: pluginData.version })
  276. }
  277. fs.rm
  278. }
  279. const [data] = await this.framework.runCliCommand('installPlugins', { plugins: [`${name}@${sourcePath ?? version}`] })
  280. const info = await this.insertOrUpdate({
  281. ...(await data.getInfo()),
  282. type: await data.getType(),
  283. isLocalInstall
  284. })
  285. if (!data.isInstallSuccessful) {
  286. throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED
  287. .setData({ name })
  288. }
  289. if (!info.targetAttribute) {
  290. throw this.app.errors.CONTENTPLUGIN_ATTR_MISSING
  291. .setData({ name })
  292. }
  293. await this.processPluginSchemas([data])
  294. return info
  295. }
  296. /**
  297. * Ensures local plugin source files are stored in the correct location and structured in an expected way
  298. * @param {Object} pluginData Plugin metadata
  299. * @param {String} sourcePath The path to the plugin source files
  300. * @returns Resolves with package data
  301. */
  302. async processPluginFiles (pluginData) {
  303. let sourcePath = pluginData.sourcePath
  304. if (sourcePath === path.basename(sourcePath)) { // no local files
  305. return { name: pluginData.name, version: sourcePath, isLocalInstall: false }
  306. }
  307. const contents = await fs.readdir(sourcePath)
  308. if (contents.length === 1) { // deal with a nested root folder
  309. sourcePath = path.join(pluginData.sourcePath, contents[0])
  310. }
  311. let pkg
  312. try { // load package data, with fall-back to bower
  313. try {
  314. pkg = await this.readJson(path.join(sourcePath, 'package.json'))
  315. } catch (e) {
  316. pkg = await this.readJson(path.join(sourcePath, 'bower.json'))
  317. }
  318. pkg.sourcePath = path.join(this.getConfig('pluginDir'), pkg.name)
  319. pkg.isLocalInstall = true
  320. } catch (e) {
  321. throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
  322. }
  323. // move the files into the persistent location
  324. await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
  325. await fs.rm(sourcePath, { recursive: true })
  326. return pkg
  327. }
  328. /**
  329. * Updates a single plugin
  330. * @param {String} _id The _id for the plugin to update
  331. * @return Resolves with update data
  332. */
  333. async updatePlugin (_id) {
  334. const [{ name }] = await this.find({ _id })
  335. const [pluginData] = await this.framework.runCliCommand({ plugins: [name] })
  336. const p = await this.update({ name }, pluginData._sourceInfo)
  337. this.log('info', `successfully updated plugin ${p.name}@${p.version}`)
  338. return p
  339. }
  340. /** @override */
  341. serveSchema () {
  342. return async (req, res, next) => {
  343. try {
  344. const plugin = await this.get({ name: req.apiData.query.type }) || {}
  345. const schema = await this.getSchema(plugin.schemaName)
  346. if (!schema) {
  347. return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
  348. }
  349. res.type('application/schema+json').json(schema)
  350. } catch (e) {
  351. return next(e)
  352. }
  353. }
  354. }
  355. /**
  356. * Express request handler for installing a plugin (also used for updating via zip upload).
  357. * @param {external:ExpressRequest} req
  358. * @param {external:ExpressResponse} res
  359. * @param {Function} next
  360. */
  361. async installHandler (req, res, next) {
  362. try {
  363. const [pluginData] = await this.installPlugins([
  364. [
  365. req.body.name,
  366. req?.fileUpload?.files?.file?.[0]?.filepath ?? req.body.version
  367. ]
  368. ], {
  369. force: req.body.force === 'true' || req.body.force === true,
  370. strict: true
  371. })
  372. res.status(this.mapStatusCode('post')).send(pluginData)
  373. } catch (error) {
  374. if (error.code === this.app.errors.CONTENTPLUGIN_INSTALL_FAILED.code) {
  375. error.data.errors = error.data.errors.map(req.translate)
  376. }
  377. res.sendError(error)
  378. }
  379. }
  380. /**
  381. * Express request handler for updating a plugin
  382. * @param {external:ExpressRequest} req
  383. * @param {external:ExpressResponse} res
  384. * @param {Function} next
  385. */
  386. async updateHandler (req, res, next) {
  387. try {
  388. const pluginData = await this.updatePlugin(req.params._id)
  389. res.status(this.mapStatusCode('put')).send(pluginData)
  390. } catch (error) {
  391. return next(error)
  392. }
  393. }
  394. /**
  395. * Express request handler for retrieving uses of a single plugin
  396. * @param {external:ExpressRequest} req
  397. * @param {external:ExpressResponse} res
  398. * @param {Function} next
  399. */
  400. async usesHandler (req, res, next) {
  401. try {
  402. const data = await this.getPluginUses(req.params._id)
  403. res.status(this.mapStatusCode('put')).send(data)
  404. } catch (error) {
  405. return next(error)
  406. }
  407. }
  408. }
  409. export default ContentPluginModule