You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/server/models/pages.js

502 lines
14 KiB

const Model = require('objection').Model
const _ = require('lodash')
const JSBinType = require('js-binary').Type
const pageHelper = require('../helpers/page')
const path = require('path')
const fs = require('fs-extra')
const yaml = require('js-yaml')
const striptags = require('striptags')
const emojiRegex = require('emoji-regex')
const he = require('he')
const CleanCSS = require('clean-css')
const TurndownService = require('turndown')
const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
const cheerio = require('cheerio')
const PageConverter = require('./page-converter')
/* global WIKI */
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]*)*/
}
const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
// const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
/**
* 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'},
hash: {type: 'string'},
title: {type: 'string'},
description: {type: 'string'},
isPublished: {type: 'boolean'},
privateNS: {type: 'string'},
publishStartDate: {type: 'string'},
publishEndDate: {type: 'string'},
content: {type: 'string'},
contentType: {type: 'string'},
createdAt: {type: 'string'},
updatedAt: {type: 'string'}
}
}
}
static get jsonAttributes() {
return ['extra']
}
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'
}
},
links: {
relation: Model.HasManyRelation,
modelClass: require('./pageLinks'),
join: {
from: 'pages.id',
to: 'pageLinks.pageId'
}
},
author: {
relation: Model.BelongsToOneRelation,
modelClass: require('./users'),
join: {
from: 'pages.authorId',
to: 'users.id'
}
},
creator: {
relation: Model.BelongsToOneRelation,
modelClass: require('./users'),
join: {
from: 'pages.creatorId',
to: 'users.id'
}
},
editor: {
relation: Model.BelongsToOneRelation,
modelClass: require('./editors'),
join: {
from: 'pages.editorKey',
to: 'editors.key'
}
},
locale: {
relation: Model.BelongsToOneRelation,
modelClass: require('./locales'),
join: {
from: 'pages.localeCode',
to: 'locales.code'
}
}
}
}
$beforeUpdate() {
this.updatedAt = new Date().toISOString()
}
$beforeInsert() {
this.createdAt = new Date().toISOString()
this.updatedAt = new Date().toISOString()
}
/**
* Solving the violates foreign key constraint using cascade strategy
* using static hooks
* @see https://vincit.github.io/objection.js/api/types/#type-statichookarguments
*/
static async beforeDelete({ asFindQuery }) {
const page = await asFindQuery().select('id')
await WIKI.models.comments.query().delete().where('pageId', page[0].id)
}
/**
* Cache Schema
*/
static get cacheSchema() {
return new JSBinType({
id: 'uint',
authorId: 'uint',
authorName: 'string',
createdAt: 'string',
creatorId: 'uint',
creatorName: 'string',
description: 'string',
editorKey: 'string',
isPrivate: 'boolean',
isPublished: 'boolean',
publishEndDate: 'string',
publishStartDate: 'string',
contentType: 'string',
render: 'string',
tags: [
{
tag: 'string',
title: 'string'
}
],
extra: {
js: 'string',
css: 'string'
},
title: 'string',
toc: 'string',
updatedAt: 'string'
})
}
/**
* Inject page metadata into contents
*
* @returns {string} Page Contents with Injected Metadata
*/
injectMetadata () {
return pageHelper.injectPageMetadata(this)
}
/**
* Get the page's file extension based on content type
*
* @returns {string} File Extension
*/
getFileExtension() {
return pageHelper.getFileExtension(this.contentType)
}
/**
* Parse injected page metadata from raw content
*
* @param {String} raw Raw file contents
* @param {String} contentType Content Type
* @returns {Object} Parsed Page Metadata with Raw Content
*/
static parseMetadata (raw, contentType) {
let result
try {
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
}
} catch (err) {
WIKI.logger.warn('Failed to parse page metadata. Invalid syntax.')
}
return {
content: raw
}
}
/**
* Create a New Page
*
* @param {Object} opts Page Properties
* @returns {Promise} Promise of the Page Model Instance
*/
static async createPage(opts) {
// -> Validate path
if (opts.path.includes('.') || opts.path.includes(' ') || opts.path.includes('\\') || opts.path.includes('//')) {
throw new WIKI.Error.PageIllegalPath()
}
// -> Remove trailing slash
if (opts.path.endsWith('/')) {
opts.path = opts.path.slice(0, -1)
}
// -> Remove starting slash
if (opts.path.startsWith('/')) {
opts.path = opts.path.slice(1)
}
// -> Check for page access
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
locale: opts.locale,
path: opts.path
})) {
throw new WIKI.Error.PageDeleteForbidden()
}
// -> Check for duplicate
const dupCheck = await WIKI.models.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
if (dupCheck) {
throw new WIKI.Error.PageDuplicateCreate()
}
// -> Check for empty content
if (!opts.content || _.trim(opts.content).length < 1) {
throw new WIKI.Error.PageEmptyContent()
}
// -> Format CSS Scripts
let scriptCss = ''
if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
locale: opts.locale,
path: opts.path
})) {
if (!_.isEmpty(opts.scriptCss)) {
scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
} else {
scriptCss = ''
}
}
// -> Format JS Scripts
let scriptJs = ''
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
locale: opts.locale,
path: opts.path
})) {
scriptJs = opts.scriptJs || ''
}
// -> Create page
await WIKI.models.pages.query().insert({
authorId: opts.user.id,
content: opts.content,
creatorId: opts.user.id,
contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
description: opts.description,
editorKey: opts.editor,
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
isPrivate: opts.isPrivate,
isPublished: opts.isPublished,
localeCode: opts.locale,
path: opts.path,
publishEndDate: opts.publishEndDate || '',
publishStartDate: opts.publishStartDate || '',
title: opts.title,
toc: '[]',
extra: JSON.stringify({
js: scriptJs,
css: scriptCss
})
})
const page = await WIKI.models.pages.getPageFromDb({
path: opts.path,
locale: opts.locale,
userId: opts.user.id,
isPrivate: opts.isPrivate
})
// -> Save Tags
if (opts.tags && opts.tags.length > 0) {
await WIKI.models.tags.associateTags({ tags: opts.tags, page })
}
// -> Render page to HTML
await WIKI.models.pages.renderPage(page)
// -> Rebuild page tree
await WIKI.models.pages.rebuildTree()
// -> Add to Search Index
const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
await WIKI.data.searchEngine.created(page)
// -> Add to Storage
if (!opts.skipStorage) {
await WIKI.models.storage.pageEvent({
event: 'created',
page
})
}
// -> Reconnect Links
await WIKI.models.pages.reconnectLinks({
locale: page.localeCode,
path: page.path,
mode: 'create'
})
// -> Get latest updatedAt
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
return page
}
/**
* Update an Existing Page
*
* @param {Object} opts Page Properties
* @returns {Promise} Promise of the Page Model Instance
*/
static async updatePage(opts) {
// -> Fetch original page
const ogPage = await WIKI.models.pages.query().findById(opts.id)
if (!ogPage) {
throw new Error('Invalid Page Id')
}
// -> Check for page access
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
locale: ogPage.localeCode,
path: ogPage.path
})) {
throw new WIKI.Error.PageUpdateForbidden()
}
// -> Check for empty content
if (!opts.content || _.trim(opts.content).length < 1) {
throw new WIKI.Error.PageEmptyContent()
}
// -> Create version snapshot
await WIKI.models.pageHistory.addVersion({
...ogPage,
isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
action: opts.action ? opts.action : 'updated',
versionDate: ogPage.updatedAt
})
// -> Format Extra Properties
if (!_.isPlainObject(ogPage.extra)) {
ogPage.extra = {}
}
// -> Format CSS Scripts
let scriptCss = _.get(ogPage, 'extra.css', '')
if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
locale: opts.locale,
path: opts.path
})) {
if (!_.isEmpty(opts.scriptCss)) {
scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
} else {
scriptCss = ''
}
}
// -> Format JS Scripts
let scriptJs = _.get(ogPage, 'extra.js', '')
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
locale: opts.locale,
path: opts.path
})) {
scriptJs = opts.scriptJs || ''
}
// -> Update page
await WIKI.models.pages.query().patch({
authorId: opts.user.id,
content: opts.content,
description: opts.description,
isPublished: opts.isPublished === true || opts.isPublished === 1,
publishEndDate: opts.publishEndDate || '',
publishStartDate: opts.publishStartDate || '',
title: opts.title,
extra: JSON.stringify({
...ogPage.extra,
js: scriptJs,
css: scriptCss
})
}).where('id', ogPage.id)
let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
// -> Save Tags
await WIKI.models.tags.associateTags({ tags: opts.tags, page })
// -> Render page to HTML
await WIKI.models.pages.renderPage(page)
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
// -> Update Search Index
const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
await WIKI.data.searchEngine.updated(page)
// -> Update on Storage
if (!opts.skipStorage) {
await WIKI.models.storage.pageEvent({
event: 'updated',
page
})
}
// -> Perform move?
if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
// -> Check target path access
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
locale: opts.locale,
path: opts.path
})) {
throw new WIKI.Error.PageMoveForbidden()
}
await WIKI.models.pages.movePage({
id: page.id,
destinationLocale: opts.locale,
destinationPath: opts.path,
user: opts.user
})
} else {
// -> Update title of page tree entry
await WIKI.models.knex.table('pageTree').where({
pageId: page.id
}).update('title', page.title)
}
// -> Get latest updatedAt
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
return page
}
/**
* Convert Page to another editor/content type
*
* @param {Object} opts Page Properties { id, editor, user }
* @returns {Promise} Promise of the Page Model Instance
*/
static async convertPage(opts) {
return PageConverter.convertPage(opts)
}
}