pages.js 11.3 KB
Newer Older
1
const Model = require('objection').Model
2
const _ = require('lodash')
3 4 5 6
const JSBinType = require('js-binary').Type
const pageHelper = require('../helpers/page')
const path = require('path')
const fs = require('fs-extra')
Nick's avatar
Nick committed
7
const yaml = require('js-yaml')
8 9
const striptags = require('striptags')
const emojiRegex = require('emoji-regex')
10

11 12
/* global WIKI */

Nick's avatar
Nick committed
13 14 15 16 17 18
const frontmatterRegex = {
  html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
  legacy: /^(<!-- TITLE: ?([\w\W]+?) -{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) -{2}>)?(?:\n|\r)*([\w\W]*)*/i,
  markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
}

19 20 21
const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig

22 23 24 25 26 27 28 29 30 31 32 33 34 35
/**
 * Pages model
 */
module.exports = class Page extends Model {
  static get tableName() { return 'pages' }

  static get jsonSchema () {
    return {
      type: 'object',
      required: ['path', 'title'],

      properties: {
        id: {type: 'integer'},
        path: {type: 'string'},
36
        hash: {type: 'string'},
37 38 39
        title: {type: 'string'},
        description: {type: 'string'},
        isPublished: {type: 'boolean'},
40
        privateNS: {type: 'string'},
41 42 43
        publishStartDate: {type: 'string'},
        publishEndDate: {type: 'string'},
        content: {type: 'string'},
44
        contentType: {type: 'string'},
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73

        createdAt: {type: 'string'},
        updatedAt: {type: 'string'}
      }
    }
  }

  static get relationMappings() {
    return {
      tags: {
        relation: Model.ManyToManyRelation,
        modelClass: require('./tags'),
        join: {
          from: 'pages.id',
          through: {
            from: 'pageTags.pageId',
            to: 'pageTags.tagId'
          },
          to: 'tags.id'
        }
      },
      author: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./users'),
        join: {
          from: 'pages.authorId',
          to: 'users.id'
        }
      },
74 75 76 77 78 79 80 81
      creator: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./users'),
        join: {
          from: 'pages.creatorId',
          to: 'users.id'
        }
      },
NGPixel's avatar
NGPixel committed
82 83 84 85 86 87 88 89
      editor: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./editors'),
        join: {
          from: 'pages.editorKey',
          to: 'editors.key'
        }
      },
90 91 92 93
      locale: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./locales'),
        join: {
NGPixel's avatar
NGPixel committed
94
          from: 'pages.localeCode',
95 96 97 98 99 100 101 102 103 104 105 106 107
          to: 'locales.code'
        }
      }
    }
  }

  $beforeUpdate() {
    this.updatedAt = new Date().toISOString()
  }
  $beforeInsert() {
    this.createdAt = new Date().toISOString()
    this.updatedAt = new Date().toISOString()
  }
108

109 110
  static get cacheSchema() {
    return new JSBinType({
Nicolas Giard's avatar
Nicolas Giard committed
111
      id: 'uint',
112 113 114 115 116 117 118 119 120 121 122 123
      authorId: 'uint',
      authorName: 'string',
      createdAt: 'string',
      creatorId: 'uint',
      creatorName: 'string',
      description: 'string',
      isPrivate: 'boolean',
      isPublished: 'boolean',
      publishEndDate: 'string',
      publishStartDate: 'string',
      render: 'string',
      title: 'string',
124
      toc: 'string',
125 126
      updatedAt: 'string'
    })
127 128
  }

129 130 131 132
  /**
   * Inject page metadata into contents
   */
  injectMetadata () {
133
    return pageHelper.injectPageMetadata(this)
134 135
  }

Nick's avatar
Nick committed
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
  /**
   * Parse injected page metadata from raw content
   *
   * @param {String} raw Raw file contents
   * @param {String} contentType Content Type
   */
  static parseMetadata (raw, contentType) {
    let result
    switch (contentType) {
      case 'markdown':
        result = frontmatterRegex.markdown.exec(raw)
        if (result[2]) {
          return {
            ...yaml.safeLoad(result[2]),
            content: result[3]
          }
        } else {
          // Attempt legacy v1 format
          result = frontmatterRegex.legacy.exec(raw)
          if (result[2]) {
            return {
              title: result[2],
              description: result[4],
              content: result[5]
            }
          }
        }
        break
      case 'html':
        result = frontmatterRegex.html.exec(raw)
        if (result[2]) {
          return {
            ...yaml.safeLoad(result[2]),
            content: result[3]
          }
        }
        break
    }
    return {
      content: raw
    }
  }

179
  static async createPage(opts) {
180
    await WIKI.models.pages.query().insert({
181 182
      authorId: opts.authorId,
      content: opts.content,
183
      creatorId: opts.authorId,
184
      contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
185 186
      description: opts.description,
      editorKey: opts.editor,
187
      hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
188 189 190 191
      isPrivate: opts.isPrivate,
      isPublished: opts.isPublished,
      localeCode: opts.locale,
      path: opts.path,
192 193 194 195
      publishEndDate: opts.publishEndDate || '',
      publishStartDate: opts.publishStartDate || '',
      title: opts.title,
      toc: '[]'
196
    })
197 198 199 200 201 202
    const page = await WIKI.models.pages.getPageFromDb({
      path: opts.path,
      locale: opts.locale,
      userId: opts.authorId,
      isPrivate: opts.isPrivate
    })
203 204

    // -> Render page to HTML
205
    await WIKI.models.pages.renderPage(page)
206 207 208 209

    // -> Add to Search Index
    const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
    page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
210
    await WIKI.data.searchEngine.created(page)
211 212

    // -> Add to Storage
Nick's avatar
Nick committed
213 214 215 216 217 218
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'created',
        page
      })
    }
219

220 221 222 223
    return page
  }

  static async updatePage(opts) {
224
    const ogPage = await WIKI.models.pages.query().findById(opts.id)
225 226 227
    if (!ogPage) {
      throw new Error('Invalid Page Id')
    }
Nicolas Giard's avatar
Nicolas Giard committed
228 229
    await WIKI.models.pageHistory.addVersion({
      ...ogPage,
Nick's avatar
Nick committed
230
      isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
Nicolas Giard's avatar
Nicolas Giard committed
231 232
      action: 'updated'
    })
233
    await WIKI.models.pages.query().patch({
234 235 236
      authorId: opts.authorId,
      content: opts.content,
      description: opts.description,
Nick's avatar
Nick committed
237
      isPublished: opts.isPublished === true || opts.isPublished === 1,
238 239
      publishEndDate: opts.publishEndDate || '',
      publishStartDate: opts.publishStartDate || '',
240
      title: opts.title
241
    }).where('id', ogPage.id)
242 243 244 245 246 247
    const page = await WIKI.models.pages.getPageFromDb({
      path: ogPage.path,
      locale: ogPage.localeCode,
      userId: ogPage.authorId,
      isPrivate: ogPage.isPrivate
    })
248 249

    // -> Render page to HTML
250
    await WIKI.models.pages.renderPage(page)
251 252 253 254

    // -> Update Search Index
    const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
    page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
255
    await WIKI.data.searchEngine.updated(page)
256 257

    // -> Update on Storage
Nick's avatar
Nick committed
258 259 260 261 262 263
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'updated',
        page
      })
    }
264 265
    return page
  }
266

Nicolas Giard's avatar
Nicolas Giard committed
267
  static async deletePage(opts) {
Nick's avatar
Nick committed
268 269 270 271 272 273 274 275 276
    let page
    if (_.has(opts, 'id')) {
      page = await WIKI.models.pages.query().findById(opts.id)
    } else {
      page = await await WIKI.models.pages.query().findOne({
        path: opts.path,
        localeCode: opts.locale
      })
    }
Nicolas Giard's avatar
Nicolas Giard committed
277 278 279 280 281 282 283 284 285
    if (!page) {
      throw new Error('Invalid Page Id')
    }
    await WIKI.models.pageHistory.addVersion({
      ...page,
      action: 'deleted'
    })
    await WIKI.models.pages.query().delete().where('id', page.id)
    await WIKI.models.pages.deletePageFromCache(page)
286 287

    // -> Delete from Search Index
288
    await WIKI.data.searchEngine.deleted(page)
289 290

    // -> Delete from Storage
Nick's avatar
Nick committed
291 292 293 294 295 296
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'deleted',
        page
      })
    }
Nicolas Giard's avatar
Nicolas Giard committed
297 298
  }

299
  static async renderPage(page) {
300 301 302 303 304 305
    const renderJob = await WIKI.scheduler.registerJob({
      name: 'render-page',
      immediate: true,
      worker: true
    }, page.id)
    return renderJob.finished
306
  }
307 308 309 310 311

  static async getPage(opts) {
    let page = await WIKI.models.pages.getPageFromCache(opts)
    if (!page) {
      page = await WIKI.models.pages.getPageFromDb(opts)
312 313 314
      if (page) {
        await WIKI.models.pages.savePageToCache(page)
      }
315 316 317 318 319
    }
    return page
  }

  static async getPageFromDb(opts) {
320
    const queryModeID = _.isNumber(opts)
321
    return WIKI.models.pages.query()
322 323 324 325
      .column([
        'pages.*',
        {
          authorName: 'author.name',
326 327 328
          authorEmail: 'author.email',
          creatorName: 'creator.name',
          creatorEmail: 'creator.email'
329 330 331 332
        }
      ])
      .joinRelation('author')
      .joinRelation('creator')
333 334 335
      .where(queryModeID ? {
        'pages.id': opts
      } : {
336 337 338 339
        'pages.path': opts.path,
        'pages.localeCode': opts.locale
      })
      .andWhere(builder => {
340
        if (queryModeID) return
341 342 343 344 345 346 347 348
        builder.where({
          'pages.isPublished': true
        }).orWhere({
          'pages.isPublished': false,
          'pages.authorId': opts.userId
        })
      })
      .andWhere(builder => {
349
        if (queryModeID) return
350 351 352 353 354 355 356 357 358 359 360 361
        if (opts.isPrivate) {
          builder.where({ 'pages.isPrivate': true, 'pages.privateNS': opts.privateNS })
        } else {
          builder.where({ 'pages.isPrivate': false })
        }
      })
      .first()
  }

  static async savePageToCache(page) {
    const cachePath = path.join(process.cwd(), `data/cache/${page.hash}.bin`)
    await fs.outputFile(cachePath, WIKI.models.pages.cacheSchema.encode({
Nicolas Giard's avatar
Nicolas Giard committed
362
      id: page.id,
363 364 365 366 367 368
      authorId: page.authorId,
      authorName: page.authorName,
      createdAt: page.createdAt,
      creatorId: page.creatorId,
      creatorName: page.creatorName,
      description: page.description,
Nicolas Giard's avatar
Nicolas Giard committed
369 370
      isPrivate: page.isPrivate === 1 || page.isPrivate === true,
      isPublished: page.isPublished === 1 || page.isPublished === true,
371 372 373 374
      publishEndDate: page.publishEndDate,
      publishStartDate: page.publishStartDate,
      render: page.render,
      title: page.title,
Nicolas Giard's avatar
Nicolas Giard committed
375
      toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400
      updatedAt: page.updatedAt
    }))
  }

  static async getPageFromCache(opts) {
    const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' })
    const cachePath = path.join(process.cwd(), `data/cache/${pageHash}.bin`)

    try {
      const pageBuffer = await fs.readFile(cachePath)
      let page = WIKI.models.pages.cacheSchema.decode(pageBuffer)
      return {
        ...page,
        path: opts.path,
        localeCode: opts.locale,
        isPrivate: opts.isPrivate
      }
    } catch (err) {
      if (err.code === 'ENOENT') {
        return false
      }
      WIKI.logger.error(err)
      throw err
    }
  }
Nicolas Giard's avatar
Nicolas Giard committed
401 402 403 404

  static async deletePageFromCache(page) {
    return fs.remove(path.join(process.cwd(), `data/cache/${page.hash}.bin`))
  }
405 406 407 408 409 410 411 412 413 414

  static cleanHTML(rawHTML = '') {
    return striptags(rawHTML || '')
      .replace(emojiRegex(), '')
      .replace(htmlEntitiesRegex, '')
      .replace(punctuationRegex, ' ')
      .replace(/(\r\n|\n|\r)/gm, ' ')
      .replace(/\s\s+/g, ' ')
      .split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
  }
415
}