adapt-authoring-adaptframework/lib/AdaptFrameworkModule.js

import { AbstractModule, Hook } from 'adapt-authoring-core'
import AdaptFrameworkBuild from './AdaptFrameworkBuild.js'
import AdaptFrameworkImport from './AdaptFrameworkImport.js'
import ApiDefs from './apidefs.js'
import fs from 'fs/promises'
import FWUtils from './AdaptFrameworkUtils.js'
import path from 'path'
import semver from 'semver'

/**
 * 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 imported. The AdaptFrameworkImport instance is passed to any observers.
     * @type {Hook}
     */
    this.preImportHook = new Hook({ mutable: true })
    /**
     * Invoked after a course has been imported. The AdaptFrameworkImport instance is passed to any observers.
     * @type {Hook}
     */
    this.postImportHook = 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 = []

    const content = await this.app.waitForModule('content')
    content.accessCheckHook.tap(this.checkContentAccess.bind(this))

    await this.installFramework()

    if (this.app.args['update-framework'] === true) {
      await this.updateFramework()
    }
    this._version = await this.runCliCommand('getCurrentFrameworkVersion')

    await Promise.all([this.loadSchemas(), this.initRoutes()])

    this.logStatus()
  }

  /**
   * Reference to AdaptFrameworkUtils#runCliCommand
   */
  get runCliCommand () {
    return FWUtils.runCliCommand
  }

  /**
   * Semver formatted version number of the local framework copy
   * @type {String}
   */
  get version () {
    return this._version
  }

  /**
   * Installs a local copy of the Adapt framework
   * @return {Promise}
   */
  async installFramework (version, force = false) {
    this.log('verbose', 'INSTALL')
    try {
      try {
        await fs.readFile(path.resolve(this.path, 'package.json'))
        if (force) {
          this.log('verbose', 'INSTALL forcing new framework install')
        } else {
          return this.log('verbose', 'INSTALL no action, force !== true')
        }
        await fs.rm(this.path, { recursive: true })
      } catch (e) {
        // package is missing, an install is required
      }
      await this.runCliCommand('installFramework', { version })
    } catch (e) {
      this.log('error', `failed to install framework, ${e.message}`)
      throw this.app.errors.FW_INSTALL_FAILED
    }
    this.log('verbose', 'INSTALL hook invoke')
    await this.postInstallHook.invoke()
  }

  /**
   * Updates the local copy of the Adapt framework
   * @return {Promise}
   */
  async getLatestVersion () {
    try {
      return semver.clean(await this.runCliCommand('getLatestFrameworkVersion'))
    } 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 FWUtils.readJson(path.resolve(this.path, 'adapt.json'))
    return Object.entries(manifest.dependencies)
  }

  /**
   * Retrieves the locally installed plugins
   * @return {Promise}
   */
  async getInstalledPlugins () {
    return this.runCliCommand('getInstalledPlugins')
  }

  /**
   * Updates the local copy of the Adapt framework
   * @param {string} version The version to update to
   * @return {Promise}
   */
  async updateFramework (version) {
    try {
      await this.runCliCommand('updateFramework', { version })
      this._version = await this.runCliCommand('getCurrentFrameworkVersion')
    } 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 this.runCliCommand('getLatestFrameworkVersion')

    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 this.runCliCommand('getSchemaPaths')).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.createdBy.toString() === userId || 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/{*splat}',
      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) {
    const builder = new AdaptFrameworkBuild(options)
    builder.preBuildHook.tap(() => this.preBuildHook.invoke(builder))
    builder.postBuildHook.tap(() => this.postBuildHook.invoke(builder))
    return builder.build()
  }

  /**
   * Imports a single Adapt framework course
   * @param {AdaptFrameworkImportOptions} options
   * @return {AdaptFrameworkImportSummary}
   */
  async importCourse (options) {
    const importer = new AdaptFrameworkImport(options)
    importer.preImportHook.tap(() => this.preImportHook.invoke(importer))
    importer.postImportHook.tap(() => this.postImportHook.invoke(importer))
    return importer.import()
  }
}

export default AdaptFrameworkModule