Source: ressource/controllerApi.js


'use strict'
const flow = require('an-flow')
const { isEmpty } = require('lodash')
const request = require('request')
const { parse, stringify } = require('sesajstools')
const { update } = require('sesajstools/utils/object')

const config = require('../config')
const configRessource = require('./config')
const Ref = require('../../constructors/Ref')

const { getBaseId, getBaseIdFromRid, getRidComponents } = require('sesatheque-client/dist/server/sesatheques')
const { getJstreeChildren, toJstree } = require('sesatheque-client/dist/server/jstree/convert')

const myBaseId = config.application.baseId

/**
 * Ajoute une erreur dans les logs
 * @param {Object} data Si propriété rid ira dans dataError.log (error.log sinon)
 */
function notifyError (data) {
  // on remplace les Error par leur stack avant stringify (pas en profondeur)
  Object.keys(data).forEach(k => {
    if (data[k] && data[k].stack) data[k] = data[k].stack
  })
  const message = 'notifyError : ' + stringify(data)
  if (data.rid) log.dataError(message)
  else log.error(message)
}

/**
 * Controleur de la route /api/ (qui répond en json) pour les ressources
 * Toutes les routes contenant /public/ ignorent la session (cookies viré par varnish,
 * cela permet de mettre le résultat en cache et devrait être privilégié pour les ressources publiques)
 *
 * Tout ce qui renvoie une ressource (ou un oid pour du post) accepte en queryString
 * - format=(ref|ressource|full) ref renvoie une ref (toujours avec les _droits), full ajoute la résolution des id (auteurs, relations, groupes…)
 * - droits=1 pour ajouter une propriété _droits (string contenant des lettres parmi RWD)
 *
 * @Controller controllerApi
 */
module.exports = function (component) {
  component.controller('api', function ($ressourceRepository, $ressourceConverter, $ressourceControl, $accessControl, $personneControl, $personneRepository, $json, EntityRessource, EntityExternalRef, $ressourceFetch, $ressourceRemote, $ressourceAutocomplete) {
    /**
     * Efface une ressource d'après son id, appellera $json.sendOk avec deleted:oid (ou une erreur)
     * @private
     * @param {Context} context
     * @param id ou oid ou origine/idOrigine
     */
    function deleteAndSend (context, id) {
      log.debug('dans cb api deleteRessource ' + id)
      // faut charger la ressource pour vérifier les droits (elle est probablement en cache)
      $ressourceRepository.load(id, function (error, ressource) {
        if (error) return $json.sendKo(context, error)
        if (!ressource) return $json.notFound(context, `La ressource ${id} n’existe pas`)
        if (!$accessControl.hasPermission('delete', context, ressource)) return $json.denied(context, 'Vous n’avez pas de droits suffisants pour supprimer cette ressource')
        $ressourceRepository.remove(ressource.oid, function (error) {
          if (error) return $json.sendKo(context, error)
          $json.sendOk(context, { deleted: ressource.oid })
        })
      })
    }

    /**
     * Renvoie l'id trouvé dans le post ou le get (en acceptant les propriétés id, oid ou origine&idOrigine, en GET ou POST)
     * @private
     * @param {Context} context
     * @returns {string} oid ou origine/idOrigine ou undefined
     */
    function extractId (context) {
      let id
      if (context.post.origine && context.post.idOrigine) id = context.post.origine + '/' + context.post.idOrigine
      else if (context.get.origine && context.get.idOrigine) id = context.get.origine + '/' + context.get.idOrigine
      else id = context.post.oid || context.post.id || context.get.oid || context.get.id

      return id
    }

    /**
     * Répond ok pour les options delete
     * @private
     * @param {Context} context
     */
    function optionsDeleteOk (context) {
      context.setHeader('Access-Control-Allow-Methods', 'DELETE,OPTIONS')
      // context.setHeader('Access-Control-Allow-Headers', 'Origin,Content-Type,Accept')
      // et on laisse le middleware CORS faire son boulot
      context.next(null, 'OK') // ne pas renvoyer de chaîne vide sinon 404
    }

    // noinspection FunctionWithMoreThanThreeNegationsJS
    /**
     * Traite la ressource de POST /api/ressource
     * @private
     * @param {Context} context
     */
    function postRessource (context) {
      context.timeout = 5000
      const ressourcePostee = context.post
      let isCreation
      let ressourceBdd

      if (context.perf) {
        let msg = 'start-'
        if (ressourcePostee.origine && ressourcePostee.idOrigine) msg += ressourcePostee.origine + '/' + ressourcePostee.idOrigine
        else msg += ressourcePostee.oid
        log.perf(context.response, msg)
      }

      log.debug('post /api/ressource a reçu', ressourcePostee, 'api', { max: 10000 })
      // faut être authentifié ou avoir un token ok
      // (avec un token ok, hasAllRights est true mais isAuthenticated peut être false)
      if (!$accessControl.isAuthenticated(context) && !$accessControl.hasAllRights(context)) {
        return $json.denied(context, 'Vous devez être authentifié pour ajouter une ressource')
      }
      // si y'a pas qqchose pour identifier une ressource existante, faut avoir les droits de création

      flow().seq(function () {
        const next = this
        // si y'à un rid sans oid on traduit
        if (ressourcePostee.rid) {
          const [baseId, oid] = getRidComponents(ressourcePostee.rid)
          if (baseId !== myBaseId) return next(new Error(`Cette ressource doit être enregistrée sur ${baseId} et non ici`))
          if (ressourcePostee.oid && ressourcePostee.oid !== oid) return next(new Error(`oid ${ressourcePostee.oid} et rid ${ressourcePostee.rid} incohérents`))
          ressourcePostee.oid = oid
        }
        // faut la charger, ne serait-ce que pour savoir si elle existe
        if (ressourcePostee.oid) { // par oid
          $ressourceRepository.load(ressourcePostee.oid, next)
        } else if (ressourcePostee.rid) { // ou par rid
          $ressourceRepository.load(ressourcePostee.rid, next)
        } else if (ressourcePostee.origine && ressourcePostee.idOrigine) { // ou par origine/idOrigine
          $ressourceRepository.loadByOrigin(ressourcePostee.origine, ressourcePostee.idOrigine, next)
        } else {
          // ça ressemble fort à une création
          // si c'est un arbre on init enfants si c'est pas déjà fait
          if (ressourcePostee.type === 'arbre' && !ressourcePostee.enfants) ressourcePostee.enfants = []
          // si y'a pas d'origine on met la notre
          if (!ressourcePostee.origine) ressourcePostee.origine = myBaseId
          // l'idOrigine n'est pas obligatoire si c'est une création ici ($ressourceRepository.save créera une clé si besoin
          if (ressourcePostee.origine !== myBaseId && !ressourcePostee.idOrigine) {
            log.debug('ressource postée invalide', ressourcePostee)
            next(new Error('Il faut fournir oid, rid ou origine ET idOrigine'))
          } else {
            next()
          }
        }
      }).seq(function (_ressourceBdd) {
        ressourceBdd = _ressourceBdd
        isCreation = !ressourceBdd
        if (log.perf) log.perf(context.response, 'loaded')
        const errMsg = isCreation
          ? $accessControl.getDeniedMessage('create', context, ressourcePostee)
          : $accessControl.getDeniedMessage('update', context, ressourceBdd)
        if (errMsg) return $json.denied(context, errMsg)

        // on a le droit de créer ou modifier cette ressource
        if (ressourceBdd) log.debug(`auteurs venant de la bdd ${ressourceBdd.auteurs.join(' ')}, version ${ressourceBdd.version}`)
        // on ajoute la catégorie si y'en a pas et qu'on peut la déduire
        const tt = ressourcePostee.type
        if (!ressourcePostee.categories && tt) ressourcePostee.categories = configRessource.categoriesToTypes[tt]

        // le contenu est partiel si on le réclame ou si c'est un update sans titre ni catégorie
        const isMerge = ['1', 'true'].includes(context.get.merge) || (!isCreation && !ressourcePostee.titre && !ressourcePostee.categories)
        const data = isMerge ? Object.assign({}, ressourceBdd, ressourcePostee) : ressourcePostee
        $ressourceControl.valideRessourceFromPost(data, this)
      }).seq(function (cleanData) {
        log.debug('ressource après valideRessourceFromPost', cleanData, 'api', { max: 10000 })
        log.debug(`après valide auteurs ${cleanData.auteurs && cleanData.auteurs.join(' ')}, version ${cleanData.version}`)
        // la ressource est cohérente, ou avec errors/warnings et c'est writeAndOut qui gèrera
        $personneControl.checkGroupes(context, ressourceBdd, cleanData, this)
      }).seq(function (cleanData) {
        log.debug(`après checkGroupes auteurs ${cleanData.auteurs && cleanData.auteurs.join(' ')}, version ${cleanData.version}`)
        // on ajoute le user courant sur les créations
        if (isCreation) {
          const pid = $accessControl.getCurrentUserPid(context)
          if (pid) {
            // pas le cas si l'api est utilisée par un bot ayant les droits (tests par exemple)
            cleanData.auteurs = [pid]
          } else if (!$accessControl.hasAllRights(context)) {
            throw Error('personne authentifiée sans pid')
          }
        }
        $personneControl.checkPersonnes(context, ressourceBdd, cleanData, this)
      }).seq(function (cleanData) {
        // on màj ressourceBdd par simplicité (ça évitera aussi de recréer une entity en cas d'update)
        if (ressourceBdd) update(ressourceBdd, cleanData)
        else ressourceBdd = cleanData
        log.debug('ressource à sauvegarder', ressourceBdd, 'api', { max: 10000 })
        writeAndOut(context, ressourceBdd)
      }).catch(function (error) {
        $json.send(context, error)
      })
    }

    /**
     * Ajoute des relations à une ressource
     * @private
     * @param {Context} context
     */
    function postRessourceAddRelations (context) {
      context.timeout = 5000
      if (context.perf) {
        let msg = 'start-'
        if (context.post.origine && context.post.idOrigine) msg += context.post.origine + '/' + context.post.idOrigine
        else msg += context.post.oid
        log.perf(context.response, msg)
      }
      log.debug('post /api/ressource/addRelations a reçu', context.post, 'api')
      const relations = context.post.relations
      if (!relations || !relations.length) return $json.send(context, new Error('relations manquantes'))
      const id = extractId(context)
      if (!id) return $json.send(context, new Error("pas d'identifiant de ressource"))

      $ressourceRepository.load(id, function (error, ressource) {
        if (error) return $json.send(context, error)
        if (!ressource) return $json.notFound(context, `La ressource ${id} n’existe pas`)
        if (!$accessControl.hasPermission('update', context, ressource)) return $json.denied(context, 'Vous n’avez pas les droits suffisants pour modifier cette ressource')
        const errors = $ressourceConverter.addRelations(ressource, relations)
        // rien changé
        if (errors === false) $json.sendOk(context, { oid: ressource.oid })
        // y'a eu des erreurs lors de l'ajout
        else if (errors.length) $json.send(context, errors)
        // ni l'un ni l'autre, faut sauvegarder
        else writeAndOut(context, ressource)
      })
    }

    /**
     * Demande d'update des arbres contenant une Ref
     * @param context
     * @returns {*}
     */
    function postRessourceExternalUpdate (context) {
      log.debug('externalUpdate avec', context.post)
      if (!$accessControl.hasAllRights(context)) return $json.denied(context)
      try {
        if (!context.post.ref) return $json.sendKo(context, 'ref manquante')
        const ref = new Ref(context.post.ref)
        if (!ref.aliasOf) return $json.sendKo(context, 'aliasOf manquant')
        if (!ref.titre) return $json.sendKo(context, 'titre manquant')
        if (!ref.type) return $json.sendKo(context, 'type manquant')
        $ressourceRepository.updateParent(ref, function (error, nbArbres) {
          if (error) $json.sendKo(context, error)
          else $json.sendOk(context)

          // la réponse est partie, mais si aucun arbre n'a été mis à jour, c'est qu'on est enregistré pour cette ref sur sa sesatheque
          // mais qu'on ne l'utilise plus, faut virer le listener pour éviter que ça ne se reproduise.
          if (nbArbres === 0) {
            log.dataError(`On nous a averti d’une modif de ${ref.aliasOf} mais plus aucune ressource ici ne la référence, on vire le listener`)
            $ressourceRemote.unregister([ref.aliasOf], log.error)
          }
        })
      } catch (error) {
        $json.sendKo(context, error)
      }
    }

    /**
     * Register ou unregister une EntityExternalRef
     * @private
     * @param context
     */
    function postRessourceRegisterListener (context) {
      log.debug('registerListener avec', context.post)
      if (!$accessControl.hasAllRights(context)) return $json.denied(context)
      const { action, baseId, rids } = context.post
      if (!action) return $json.sendKo(context, 'action manquante')
      if (!baseId) return $json.sendKo(context, 'baseId manquante')
      if (!Array.isArray(rids) || !rids.length) return $json.sendKo(context, 'rids manquant')

      // ajouter un listener (pour prévenir baseId que rid a changé)
      if (action === 'add') {
        const warnings = []
        // on regarde si on l'a pas déjà
        flow(rids).seqEach(function (rid) {
          const nextRid = this
          // on vérifie que ça nous concerne
          const ownerBaseId = getBaseIdFromRid(rid)
          if (ownerBaseId !== myBaseId) {
            warnings.push(`Impossible de s’enregistrer sur ${myBaseId} pour une ressource qui est gérée par ${ownerBaseId} (pour ${rid})`)
            nextRid()
          }
          EntityExternalRef
            .match('baseId').equals(baseId)
            .match('rid').equals(rid)
            .grabOne(function (error, externalRef) {
              if (error) return nextRid(error)
              if (externalRef) {
                warnings.push(`${rid} avait déjà un listener pour ${baseId} sur ${myBaseId}`)
                return nextRid()
              }
              // faut la créer
              EntityExternalRef.create({ baseId, rid }).store(nextRid)
            })
        }).seq(function () {
          $json.sendOk(context, warnings.length ? { warnings } : {})
        }).catch(function (error) {
          $json.sendKo(context, error)
        })

        // retirer un listener (car baseId ne référence plus rid)
      } else if (action === 'remove') {
        const warnings = []
        let i = 0 // pour savoir à quel rid correspond le tableau externalRefs
        flow(rids).seqEach(function (rid) {
          log.debug('remove listener pour ' + rid)
          EntityExternalRef
            .match('baseId').equals(baseId)
            .match('rid').equals(rid)
            .grab(this)
          // seqEach car chaque rid fait un grab qui remonte un tableau de externalRefs
        }).seqEach(function (externalRefs) {
          log.debug(`remove ${rids[i]} remonte les extRefs`, externalRefs)
          if (externalRefs.length) {
            if (externalRefs.length > 1) {
              const msg = `Il y avait ${externalRefs.length - 1} doublon(s) exernalRef pour ${baseId} sur ${externalRefs[0].rid} (tous supprimés)`
              log.dataError(msg)
              warnings.push(msg)
            }
            externalRefs.forEach(er => er.delete(log.error))
          } else {
            warnings.push(`Aucun listener pour le rid ${rids[i]}`)
          }
          i++
          this()
        }).seq(function () {
          $json.sendOk(context, warnings.length ? { warnings } : {})
        }).catch(function (error) {
          $json.sendKo(context, error)
        })
      } else {
        $json.sendKo(context, `action ${context.post.action} inconnue (add|remove)`)
      }
    }

    /**
     * Retourne un array pour jstree
     * @private
     * @param {Context} context
     * @param error
     * @param data
     */
    function sendJsonJstreeArray (context, error, data) {
      let errorMsg
      if (error) {
        errorMsg = (typeof error === 'string') ? error : error.toString()
        $json.sendOk(context, { arrayOnly: [{ text: 'Erreur : ' + errorMsg }] })
      } else if (!Array.isArray(data)) {
        log.error(new Error("sendJsonJstreeArray appelé avec autre chose qu'un array"))
        $json.sendOk(context, data)
      } else {
        log.debug('$json va renvoyer le tableau', data, 'api')
        $json.sendOk(context, { arrayOnly: data })
      }
    }

    /**
     * Renvoie la ressource (ou l'erreur) après avoir vérifié les droits, complète ou au format de context.get.format (avec ref ça ajoute les droits)
     * @private
     * @param {Context} context
     * @param error
     * @param ressource
     */
    function sendRessource (context, error, ressource) {
      if (error) return $json.send(context, error)
      if (!ressource) return $json.notFound(context, 'Cette ressource n’existe pas.')
      if (!$accessControl.hasReadPermission(context, ressource)) return $json.denied(context)
      // on va renvoyer qq chose

      const addDroits = (data) => {
        data._droits = 'R'
        if ($accessControl.hasPermission('update', context, ressource)) data._droits += 'W'
        if ($accessControl.hasPermission('delete', context, ressource)) data._droits += 'D'
      }

      const send = (data) => {
        if (droits) addDroits(data)
        // on vire les props $* (surtout $original qui double avec les même infos)
        Object.keys(data).forEach(p => {
          if (p.substr(0, 1) === '$') delete data[p]
        })
        $json.send(context, null, data)
      }

      let { format, droits } = context.get

      // init droits, ça vient de l'url donc toujours une string
      if (['false', 'no', 'off', '0', 'undefined', 'null'].includes(droits)) droits = false
      else if (['true', 'yes', 'on', '1'].includes(droits)) droits = true
      else if (['ref', 'full'].includes(format)) droits = true
      else droits = false

      if (format === 'ref') {
        send(new Ref(ressource))
      } else if (format === 'id') {
        const { oid, rid, aliasOf } = ressource
        send({ oid, rid, aliasOf })
      } else if (format === 'full') {
        // ressource complète avec résolution des oid externes (auteurs, groupe…)
        $ressourceConverter.enhance(ressource, (error, ressource) => {
          if (error) return $json.send(context, error)
          send(ressource)
        })
      } else {
        send(ressource)
      }
    }

    /**
     * Si la ressource contient des erreurs les renvoie, sinon l'enregistre et sort avec oid et warnings éventuels
     * ou le ?format= demandé (ref ou full, le reste donnant la ressource complète)
     * @private
     * @param {Context} context
     * @param ressource
     */
    function writeAndOut (context, ressource) {
      if (!isEmpty(ressource.$errors)) return $json.send(context, ressource.$errors)
      $ressourceRepository.save(ressource, function (error, ressource) {
        if (error) return $json.send(context, error)
        log.perf(context.response, 'written')
        if (context.get.format) {
          // on veut la ressource formatée, sendRessource le gère
          sendRessource(context, null, ressource)
        } else {
          // on ne renvoie que l'oid et des warnings éventuels
          const data = { oid: ressource.oid }
          if (!isEmpty(ressource.$warnings)) {
            data.warnings = ressource.$warnings
          }
          $json.send(context, null, data)
        }
      })
    }

    /**
     * Met éventuellement à jour un titre bateau si on en a un meilleur (asynchrone, lance la màj en bdd et rend la main)
     * @param ressource
     * @param newTitre
     */
    /*
     function updateTitre(ressource, newTitre) {
       // on regarde si l'arbre nous apporte un titre que l'on aurait pas
       if (newTitre) {
         //noinspection SwitchStatementWithNoDefaultBranchJS
         switch (ressource.titre) {
               // titres par défaut mis par importMEPS
               case 'Exercice mathenpoche':
               case 'Aide mathenpoche':
               // titres par défaut mis par importLabomep
               case 'Message ou question':
               case 'Figure TracenPoche':
               case 'Test diagnostique':
               case 'Opération posée':
               case 'Exercice avec la calculatrice cassée':
               case 'Figure GeoGebra':
               case 'Page externe':
               case 'Animation interactive':
               case 'QCM interactif':
               case 'Exercice corrigé':
               case 'QCM':
               case 'Animation instrumenpoche':
               case 'Titre manquant':
               case 'Parcours interactif':
               case "Test diagnostique d'algèbre":
               case 'Exercice Calcul@TICE':
                 // on sauvegarde le nouveau titre
                 log.debug('titre de ' +ressource.oid +' changé : ' +ressource.titre +' => ' +newTitre)
                 ressource.titre = newTitre
                 $ressourceRepository.save(ressource) // pas de next, on laisse comme c'était si ça plante
             }
       }
     } /* */

    const controller = this

    /**
     * Passe au suivant pour toutes les requetes OPTIONS (traitées par le middleware cors)
     * @route OPTIONS /api/*
     */
    controller.options('*', function (context) {
      log.debug('headers de la requete options', context.request.headers, 'xhr', { max: 5000, indent: 2 })
      // on laisse le middleware CORS faire son boulot
      context.next()
    })

    /**
     * Retourne la baseId d'une baseUrl (sesatheque ou sesalab)
     * @route GET /api/baseId?baseUrl=xxx
     */
    controller.get('baseId', function (context) {
      const baseUrl = context.get.baseUrl
      if (!baseUrl) return $json.sendKo(context, 'baseUrl manquante')
      // on cherche d'abord dans les sesalabs
      const sesalab = config.sesalabs.find(sesalab => sesalab.baseUrl === baseUrl)
      if (sesalab) {
        if (sesalab.baseId) return $json.sendOk(context, { baseId: sesalab.baseId, type: 'sesalab' })
        log.error('pb de sesalab en configuration sans baseId', sesalab)
        return $json.sendKo(context, 'Problème de configuration de la Sésathèque')
      }
      // c'est pas un sesalab, on cherche une sésathèque
      const baseId = getBaseId(baseUrl, null)
      if (baseId) return $json.sendOk(context, { baseId, type: 'sesatheque' })
      $json.sendKo(context, `baseUrl ${baseUrl} inconnue`)
    })

    /**
     * Clone une ressource de la bibli courante en mettant l'utilisateur courant auteur (si c'est éditable, sinon laisse les auteurs)
     * Cumule les actions createAlias et forkAlias, sauf pour les ressources non éditables où on laisse les auteurs intact (alors que createAlias met toujours l'utilisateur courant en auteur)
     * Utilisé par le bouton dupliquer, retourne la ressource
     *
     * La route devrait être /api/ressource/clone/:oid, mais on a déjà une route qui match 2 arguments après ressource
     * (ressource/:origine/:idOrigine) donc on utilise l'action en premier
     * @route GET /api/clone/:oid
     */
    controller.get('clone/:oid', function (context) {
      const { oid } = context.arguments
      const pid = $accessControl.getCurrentUserPid(context)
      if (!pid) return $json.denied(context, 'Vous devez être authentifié pour cloner une ressource')
      $ressourceRepository.load(oid, function (error, ressource) {
        if (error) return $json.send(context, error)
        if (!ressource) return $json.notFound(context, `La ressource ${oid} n’existe pas`)
        if (!$accessControl.hasReadPermission(context, ressource)) return $json.denied(context, `Vous n’avez pas les droits suffisant pour lire la ressource ${oid}`)
        const deniedMessage = $accessControl.getCreateDeniedMessage(context, ressource.type)
        if (deniedMessage) return $json.denied(deniedMessage)
        const isEditable = configRessource.editable[ressource.type]
        // on duplique
        delete ressource.oid
        ressource.origine = config.application.baseId
        delete ressource.idOrigine
        // si c'est éditable on met le user en auteur (sinon c'est forcément un admin|éditeur et on touche à rien)
        if (isEditable) $personneControl.changePidsOnClone(ressource, pid)
        // on laisse publie et restriction à l'identique
        // modif du titre
        const chunks = /\(copie *([0-9]+)?\)/.exec(ressource.titre)
        if (chunks) {
          // déjà une copie
          const suffix = chunks[1] ? Number(chunks[1]) + 1 : 2
          ressource.titre = ressource.titre.replace(chunks[0], `(copie ${suffix})`)
        } else {
          ressource.titre += ' (copie)'
        }
        // ajout de la relation avec la ressource originale
        if (!ressource.relations) ressource.relations = []
        ressource.relations.push([configRessource.constantes.relations.estVersionDe, ressource.rid])
        delete ressource.rid
        $ressourceRepository.save(ressource, function (error, ressource) {
          if (error) return $json.sendKo(context, error)
          if (!ressource || !ressource.oid) return $json.send(context, new Error('L’enregistrement de la ressource a échoué'))
          sendRessource(context, null, ressource)
        })
      })
    })

    /**
     * Crée un alias de ressource en mettant l'utilisateur courant en auteur (de l'alias)
     * (sinon il pourra pas le supprimer)
     * Ne deviendra une vraie ressource clonée que si on l'édite
     * Retourne {@link Ref}
     * Utiliser la méthode sesatheque-client:cloneItem
     * @route GET /api/createAlias/:baseId/:oid
     */
    controller.get('createAlias/:baseId/:oid', function (context) {
      const { baseId, oid } = context.arguments
      const rid = `${baseId}/${oid}`
      const myBaseId = config.application.baseId
      const pid = $accessControl.getCurrentUserPid(context)
      flow().seq(function () {
        if (!pid) return this(new Error('Vous devez être authentifié pour créer une ressource'))
        // on accepte de cloner une ressource locale ou d'une sésathèque connue
        if (baseId === myBaseId || config.sesatheques.some(({ baseId: id }) => id === baseId)) {
          $ressourceFetch.fetchOriginal(rid, this)
        } else {
          this(new Error(`La sésathèque ${baseId} n'est pas déclarée comme source possible de cette sésathèque`))
        }
      }).seq(function (ressource) {
        log.debug('createAlias a récupéré la ressource', ressource, 'clone', { max: 5000, indent: 2 })
        // on passe par Ref pour filtrer ce qu'on garde (pour un alias c'est seulement ce que ref utilise,
        // sauf publie, restriction & relations, remis plus bas)
        const data = new Ref(ressource)
        // refonte auteurs
        $personneControl.changePidsOnClone(data, pid)
        // fix origine & co
        data.origine = config.application.baseId
        data.dateCreation = new Date()
        // une ref a une prop public mais ni publie ni restriction
        data.publie = ressource.publie
        data.restriction = ressource.restriction
        // il faut virer la clé de la ressource d'origine (elle doit rester unique), beforeStore la recréera
        if (data.cle) delete data.cle
        // la relation vers l'original est inutile pour un alias,
        // elle sera ajoutée lors de l'édition de cette alias (qui deviendra une ressource),
        // mais on doit conserver les autres relations
        if (ressource.relations && ressource.relations.length) data.relations = ressource.relations
        $ressourceRepository.save(data, this)
      }).seq(function (ressAlias) {
        const refAlias = new Ref(ressAlias)
        // le user courant peut toujours effacer l'alias
        refAlias.$droits = 'D'
        // modif autorisée sur les ressources éditables seulement
        // (qui deviendront à l'édition des ressources dérivées et plus des alias)
        if (configRessource.editable[refAlias.type]) refAlias.$droits += 'W'
        // le context.json de lassi filtre les propriétés $ au 1er niveau, on ajoute un niveau (ici la propriété clone)…
        $json.send(context, null, { clone: refAlias })
      }).catch(function (error) {
        $json.sendKo(context, error.toString())
      })
    })

    /**
     * Fork un alias et retourne la ressource créée (qui conserve l'oid de l'alias)
     * @route GET /api/forkAlias/:oid
     */
    controller.get('forkAlias/:oid', function (context) {
      const myPid = $accessControl.getCurrentUserPid(context)
      if (!myPid) return $json.denied(context, 'Vous devez être authentifié pour dupliquer un alias')

      flow()
        .seq(function () {
          $ressourceRepository.load(context.arguments.oid, this)
        })
        .seq(function (ressource) {
          if (!ressource) return $json.notFound(context, 'La ressource n\'existe pas')
          if (!$accessControl.hasReadPermission(context, ressource)) return $json.denied(context, 'Vous n’avez pas de droits suffisants pour dupliquer cette ressource')
          if (!ressource.aliasOf) return $json.sendKo(context, 'Cette ressource n’est pas un alias')

          $ressourceConverter.forkAlias(myPid, ressource, (error, forkedRessource) => {
            if (error) return $json.sendKo(context, error)
            if (!forkedRessource) throw new Error('Une erreur s’est produite pendant la duplication de cet alias (forkAlias ne remonte ni erreur ni ressource')
            sendRessource(context, null, forkedRessource)
          })
        })
        .catch(function (error) {
          $json.sendKo(context, error)
        })
    })

    /**
     * Loggue un user d'un sesalab localement, répond {success:true} ou {success:false, error:"message d'erreur"}
     * Dupliqué dans app/personne/controllerPersonne.js en html
     * @Route POST /api/connexion
     * @param {string} origine L'url de la racine du sesalab appelant (qui doit être déclaré dans le config de la sésathèque), avec préfixe http ou https
     * @param {string} token   Le token de sesalab qui servira à récupérer le user
     */
    controller.get('connexion', function (context) {
      const token = context.get.token
      let origine = context.get.origine
      const timeout = 5000
      if (token && origine) {
        if (origine.substr(-1) !== '/') origine += '/'
        if (config.sesalabsByOrigin[origine]) {
          const postOptions = {
            url: origine + 'api/utilisateur/check-token',
            json: true,
            content_type: 'charset=UTF-8',
            timeout: timeout,
            form: {
              token: token
            }
          }
          // on ne garde que le nom de domaine en origine
          const domaine = /https?:\/\/([a-z.0-9]+(:[0-9]+)?)/.exec(origine)[1] // si ça plante fallait pas mettre n'importe quoi en config
          request.post(postOptions, function (error, response, body) {
            if (error) {
              $json.send(context, error)
            } else if (body.error) {
              $json.send(context, new Error(body.error))
            } else if (body.ok && body.utilisateur) {
              // on peut connecter
              $accessControl.loginFromSesalab(context, body.utilisateur, domaine, function (error) {
                log.debug('dans cb loginFromSesalab on a en session', context.session.user)
                if (error) $json.send(context, error)
                else $json.sendOk(context, { random: +new Date() })
              })
            } else {
              const msg = 'réponse du sso sesalab incohérente (ko sans erreur) sur ' + postOptions.url
              error = new Error(msg)
              log.error(error)
              log.debug(msg, body)
              $json.send(context, error)
            }
          })
        } else {
          $json.send(context, new Error('Origine ' + origine + 'non autorisée à se connecter ici'))
        }
      } else {
        $json.send(context, new Error('token ou origine manquant'))
      }
    })

    /**
     * Déconnecte l'utilisateur courant
     * @route GET /api/deconnexion
     */
    controller.get('deconnexion', function (context) {
      if ($accessControl.isAuthenticated(context)) {
        $accessControl.logout(context)
        $json.sendOk(context)
      } else {
        $json.sendOk(context, { warning: 'Utilisateur non connecté' })
      }
    })

    /**
     * Forward un post vers un sesalab (au unload on ne peut pas poster en crossdomain,
     * on le fait en synchrone ici qui fera suivre)
     * @Route POST /api/deferPost
     */
    controller.post('deferPost', function (context) {
      // on accepte du json en text/plain (pour le sendBeacon au unload)
      const data = (typeof context.post === 'string') ? parse(context.post) : context.post
      log.debug('deferPost appelé avec', data)
      if (typeof data.deferUrl !== 'string') {
        return $json.send(context, new Error('Il faut poster une url via deferUrl'))
      }

      const url = data.deferUrl
      delete data.deferUrl

      // on peut nous envoyer en sync des messages pour notifyError
      if (url === '/api/notifyError') {
        notifyError(data)
        return $json.sendOk(context)
      }

      if (!config.sesalabs.some((sesalab) => url.indexOf(sesalab.baseUrl) === 0)) {
        const error = new Error(`deferPost appelé pour faire suivre à ${url} qui n’est pas dans les sesalab autorisés`)
        return $json.send(context, error)
      }

      const postOptions = {
        url: url,
        json: true,
        content_type: 'charset=UTF-8',
        timeout: 3000,
        headers: {
          Cookie: context.request.cookies
        },
        form: context.post
      }
      request.post(postOptions, function (error, response, body) {
        if (error) return $json.sendKo(error)
        log.debug('deferPost, après envoi vers ' + postOptions.url + ' de ', postOptions.form)
        log.debug('on récupère la réponse', response)
        log.debug('on récupère et le body', body)
        // si on renvoie rien ça donne une erreur 500 en timeout,
        // context.next() donnerait une 404 car pas de contenu
        $json.sendOk(context)
      })
    })

    /**
     * Une url pour envoyer des notifications d'erreur, à priori par un client
     * qui trouve des incohérences dans ce qu'on lui a envoyé
     * @Route POST /api/notifyError
     */
    controller.post('notifyError', function (context) {
      notifyError(context.post)
      $json.sendOk(context)
    })

    /**
     * Récupère un arbre au format jstree (cf le plugin arbre pour un exemple d'utilisation)
     * @route GET /api/jstree?ref=xx[&children=1]
     * @param {string} ref        Un oid ou origine/idOrigine
     * @param {string} [children] Passer 1 pour ne récupérer que les enfants
     */
    controller.get('jstree', function (context) {
      const id = context.get.rid || context.get.aliasOf || context.get.id || context.get.oid
      const onlyChildren = !!context.get.children
      if (id) {
        $ressourceRepository.load(id, function (error, ressource) {
          if (error) {
            sendJsonJstreeArray(context, error)
          } else if (ressource && $accessControl.hasReadPermission(context, ressource)) {
            // on ajoute baseId s'il n'y est pas
            if (!ressource.baseId) ressource.baseId = config.application.baseId
            if (onlyChildren) {
              if (ressource.type === 'arbre') {
                const jstData = getJstreeChildren(ressource)
                // log.debug('à partir de', ressource, 'avirer', {max: 5000, indent: 2})
                // log.debug('on récupère les enfants', jstData, 'avirer', {max: 5000, indent: 2})
                sendJsonJstreeArray(context, null, jstData)
              } else {
                sendJsonJstreeArray(context, "impossible de réclamer les enfants d'une ressource qui n'est pas un arbre")
              }
            } else {
              const jstData = toJstree(ressource)
              sendJsonJstreeArray(context, null, [jstData]) // il veut toujours un Array (liste d'élément), ici le root
            }
          } else {
            sendJsonJstreeArray(context, 'la ressource ' + id + " n’existe pas ou vous n'avez pas suffisamment de droits pour y accéder")
          }
        })
      } else {
        sendJsonJstreeArray(context, 'il faut fournir un id de ressource')
      }
    })

    /**
     * Retourne une liste de filtres de recherche qui matchent pattern
     * Retourne un objet {filters: searchFilter[]}, chaque élément du tableau étant searchFilter ({index: string, value: string|number})
     * @route GET /api/autocomplete/:pattern
     */
    controller.get('autocomplete/:pattern', function (context) {
      const { pattern } = context.arguments
      if (pattern.length < 2) return $json.sendKo(context, 'Il faut au moins deux caractères')
      const filters = $ressourceAutocomplete.getFilters(context.arguments.pattern)
      // ça change très rarement, pas grave si faut attendre 3j pour qu'une modif de valeur
      // d'un champ contrôlé soit reflétée sur l'autocomplete
      context.setPublicCache('3d')
      $json.sendOk(context, { filters })
    })

    /**
     * Retourne la ressource publique et publiée (sinon 404) d'après son oid
     * Retourne {@link reponseListe}
     * @route GET /api/public/:oid
     * @param {Integer} :oid
     */
    controller.get('public/:oid', function (context) {
      const { oid } = context.arguments
      if (oid === 'getRid') return context.next() // c'est pas pour nous
      $ressourceRepository.load(oid, function (error, ressource) {
        if (error) return $json.send(context, error)
        if (!ressource) return $json.notFound(context, `La ressource ${oid} n’existe pas`)
        if ($accessControl.isPublic(ressource)) return sendRessource(context, null, ressource)
        $json.denied(context, `La ressource ${oid} n’est pas publique`)
      })
    })

    /**
     * Retourne la ressource publique et publiée (sinon 404) d'après son id d'origine
     * Retourne {@link reponseRessource}
     * @route GET /api/public/:origine/:idOrigine
     * @param {string} :origine
     * @param {string} :idOrigine
     */
    controller.get('public/:origine/:idOrigine', function (context) {
      const { idOrigine, origine } = context.arguments
      $ressourceRepository.loadByOrigin(origine, idOrigine, function (error, ressource) {
        if (error) $json.send(context, error)
        else if (ressource && (ressource.restriction === 0 || origine === 'cle')) sendRessource(context, null, ressource)
        else if (ressource) $json.denied(context, `La ressource ${origine}/${idOrigine} n’est pas publique`)
        else $json.notFound(context, `La ressource ${origine}/${idOrigine} n’existe pas`)
      })
    })

    /**
     * Retourne le rid d'une ressource (même privée, juste pour avoir la correspondance
     * origine/idOrigine => rid ou vérifier que l'id existe)
     * @route GET /api/public/getRid?id=xxx
     */
    controller.get('public/getRid', function (context) {
      const id = context.get.id
      if (id) {
        $ressourceRepository.load(id, function (error, ressource) {
          if (error) return $json.Ko(context, error)
          if (ressource) return $json.sendOk(context, { rid: ressource.rid })
          $json.notFound(context, `La ressource ${id} n’existe pas`)
        })
      } else {
        $json.sendKo(context, 'id manquant')
      }
    })

    /**
     * Denied (rerouting interne ressource => public si on a ni session ni token)
     * @internal
     * @route DEL /api/public/:origine/:idOrigine
     */
    controller.delete('public/:origine/:idOrigine', function (context) {
      $json.denied(context, 'droits insuffisant pour effacer cette ressource')
    })

    /**
     * Denied (rerouting interne ressource => public si on a ni session ni token)
     * @internal
     * @route DEL /api/public/:oid
     */
    controller.delete('public/:oid', function (context) {
      $json.denied(context, 'droits insuffisant pour effacer cette ressource')
    })

    /**
     * Create / update une ressource
     * Prend un objet ressource, éventuellement incomplète mais oid ou origine/idOrigine sont obligatoires
     * Si le titre et la catégorie sont manquants, ou que l'on passe ?merge=1 à l'url, ça merge avec la ressource
     * existante que l'on update, sinon on écrase (ou on créé si elle n'existait pas)
     *
     * Retourne {@link reponseRessourceOid} ou {@link Ref} si on le réclame avec ?format=ref
     * @route POST /api/ressource
     * @param {object} Les propriétés de la ressource
     */
    controller.post('ressource', postRessource)
    /**
     * Pour le preflight, ajoute aux headers cors habituels le header
     *   Access-Control-Allow-Methods:POST OPTIONS
     * @route OPTIONS /api/ressource
     */

    /**
     * Retourne la ressource d'après son oid (si on a les droit de lecture dessus)
     * Au format {@link reponseRessource} ou {@link Ref} si on le réclame avec ?format=ref
     * @Route GET /api/ressource/:oid
     * @param {Integer} oid
     */
    controller.get('ressource/:oid', function (context) {
      $ressourceRepository.load(context.arguments.oid, function (error, ressource) {
        sendRessource(context, error, ressource)
      })
    })

    /**
     * Retourne la ressource d'après son id d'origine (si on a les droit de lecture dessus)
     * Au format {@link reponseRessource} ou {@link Ref} si on le réclame avec ?format=ref
     * @route GET /api/ressource/:origine/:idOrigine
     * @param {string} :origine
     * @param {string} :idOrigine
     */
    controller.get('ressource/:origine/:idOrigine', function (context) {
      const { idOrigine, origine } = context.arguments
      $ressourceRepository.loadByOrigin(origine, idOrigine, function (error, ressource) {
        sendRessource(context, error, ressource)
      })
    })

    /**
     * Delete ressource par oid, retourne {@link reponseDeleted}
     * @route DELETE /api/ressource/:oid
     * @param {Integer} oid
     */
    controller.delete('ressource/:oid', function (context) {
      deleteAndSend(context, context.arguments.oid)
    })
    controller.options('ressource/:oid', optionsDeleteOk)

    /**
     * Delete par id d'origine ou par rid, retourne {@link reponseDeleted}
     * @route DEL /api/ressource/:origine/:idOrigine
     * @param {string} :origine ou baseId si c'était un rid
     * @param {string} :idOrigine ou oid si c'était un rid
     */
    controller.delete('ressource/:origine/:idOrigine', function (context) {
      const rid = context.arguments.origine + '/' + context.arguments.idOrigine
      deleteAndSend(context, rid)
    })
    controller.options('ressource/:origine/:idOrigine', optionsDeleteOk)

    /**
     * Ajoute des relations à une ressource (pour identifier la ressource on accepte dans le post oid ou origine+idOrigine ou ref)
     * Retourne {@link reponseRessourceOid} ou {@link Ref} si on le réclame avec ?format=ref
     * @param {Integer} [oid]
     * @param {string} [origine]
     * @param {string} [idOrigine]
     * @param {string} [ref]
     * @param {Array} relations
     * @route POST /api/ressource/addRelations
     */
    controller.post('ressource/addRelations', postRessourceAddRelations)
    /**
     * Pour le preflight, ajoute aux headers cors habituels le header
     *   Access-Control-Allow-Methods:POST OPTIONS
     * @route OPTIONS /api/ressource/addRelations
     */

    /**
     * Permet à une autre sésathèque d'ajouter un listener ici pour être prévenu sur une modif d'une de nos ressource
     * @route POST /api/ressource/registerListener
     */
    controller.post('ressource/registerListener', postRessourceRegisterListener)

    /**
     * Pour poster une Ref afin de mettre à jour tous les éventuels arbres qui l'utilisent
     * @route POST /api/ressource/externalUpdate
     */
    controller.post('ressource/externalUpdate', postRessourceExternalUpdate)
  })
}

/**
 * Format de la réponse à une demande de suppression
 * @typedef reponseDeleted
 * @type {Object}
 * @property {boolean} success
 * @property {string}  [error] Message d'erreur éventuel (si success vaut false)
 * @property {string}  deleted L'id passé en argument (DEPRECATED, pour compatibilité avec les versions anterieures)
 */

/**
 * Format de la réponse à une demande de liste
 * @typedef reponseListe
 * @type {Object}
 * @property {boolean}                     success
 * @property {string}                      [error] Message d'erreur éventuel
 * @property {Ref[]|Ressource[]} liste   Une liste de Ref (ou de Ressource si on le demande)
 */

/**
 * Format de la réponse à une demande de liste
 * @typedef reponseListesByPid
 * @type {Object}
 * @property {boolean} success
 * @property {string}   [error] Message d'erreur éventuel
 * @property {string[]} [warnings] Éventuelle liste de warnings (auteurs inconnus)
 * @property {object}   pidXX   Objet contenant les ressources de pidXX
 * @property {string}   pidXX.pid   rappel de son pid
 * @property {string}   pidXX.label prénom & nom
 * @property {Ref[]}    pidXX.liste Ses ressources (lisibles par le demandeur)
 */

/**
 * La réponse à une demande de ressource
 * @typedef reponseRessource
 * @type {Object}
 * @property {boolean}   success
 * @property {string}    [error]        Message d'erreur éventuel
 * @property {string[]}  [warnings]     Avertissements éventuels sur la ressource (incohérences ne justifiant pas une erreur et le rejet de l'enregistrement)
 * @property {Integer}   oid
 * @property {string}    titre
 * @property {Integer[]} categories
 * @property {string}    type
 * @property … Autre propriétés d'une ressource
 */

/**
 * La réponse à un post pour enregistrer une ressource (ou une modif)
 * @typedef reponseRessourceOid
 * @type {Object}
 * @property {boolean}  success
 * @property {string}   [error]    Message d'erreur éventuel
 * @property {string[]} [warnings] Avertissements éventuels sur la ressource (incohérences ne justifiant pas une erreur et le rejet de l'enregistrement)
 * @property {Integer}  oid
 */