pages.js 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195
  1. const Model = require('objection').Model
  2. const _ = require('lodash')
  3. const JSBinType = require('js-binary').Type
  4. const pageHelper = require('../helpers/page')
  5. const path = require('path')
  6. const fs = require('fs-extra')
  7. const yaml = require('js-yaml')
  8. const striptags = require('striptags')
  9. const emojiRegex = require('emoji-regex')
  10. const he = require('he')
  11. const CleanCSS = require('clean-css')
  12. const TurndownService = require('turndown')
  13. const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
  14. const cheerio = require('cheerio')
  15. /* global WIKI */
  16. const frontmatterRegex = {
  17. html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
  18. legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
  19. markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
  20. }
  21. const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
  22. // const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
  23. /**
  24. * Pages model
  25. */
  26. module.exports = class Page extends Model {
  27. static get tableName() { return 'pages' }
  28. static get jsonSchema () {
  29. return {
  30. type: 'object',
  31. required: ['path', 'title'],
  32. properties: {
  33. id: {type: 'integer'},
  34. path: {type: 'string'},
  35. hash: {type: 'string'},
  36. title: {type: 'string'},
  37. description: {type: 'string'},
  38. isPublished: {type: 'boolean'},
  39. privateNS: {type: 'string'},
  40. publishStartDate: {type: 'string'},
  41. publishEndDate: {type: 'string'},
  42. content: {type: 'string'},
  43. contentType: {type: 'string'},
  44. minTocLevel: {type: 'integer'},
  45. tocLevel: {type: 'integer'},
  46. tocCollapseLevel: {type: 'integer'},
  47. doUseTocDefault: {type: 'boolean'},
  48. createdAt: {type: 'string'},
  49. updatedAt: {type: 'string'}
  50. }
  51. }
  52. }
  53. static get jsonAttributes() {
  54. return ['extra']
  55. }
  56. static get relationMappings() {
  57. return {
  58. tags: {
  59. relation: Model.ManyToManyRelation,
  60. modelClass: require('./tags'),
  61. join: {
  62. from: 'pages.id',
  63. through: {
  64. from: 'pageTags.pageId',
  65. to: 'pageTags.tagId'
  66. },
  67. to: 'tags.id'
  68. }
  69. },
  70. links: {
  71. relation: Model.HasManyRelation,
  72. modelClass: require('./pageLinks'),
  73. join: {
  74. from: 'pages.id',
  75. to: 'pageLinks.pageId'
  76. }
  77. },
  78. author: {
  79. relation: Model.BelongsToOneRelation,
  80. modelClass: require('./users'),
  81. join: {
  82. from: 'pages.authorId',
  83. to: 'users.id'
  84. }
  85. },
  86. creator: {
  87. relation: Model.BelongsToOneRelation,
  88. modelClass: require('./users'),
  89. join: {
  90. from: 'pages.creatorId',
  91. to: 'users.id'
  92. }
  93. },
  94. editor: {
  95. relation: Model.BelongsToOneRelation,
  96. modelClass: require('./editors'),
  97. join: {
  98. from: 'pages.editorKey',
  99. to: 'editors.key'
  100. }
  101. },
  102. locale: {
  103. relation: Model.BelongsToOneRelation,
  104. modelClass: require('./locales'),
  105. join: {
  106. from: 'pages.localeCode',
  107. to: 'locales.code'
  108. }
  109. }
  110. }
  111. }
  112. $beforeUpdate() {
  113. this.updatedAt = new Date().toISOString()
  114. }
  115. $beforeInsert() {
  116. this.createdAt = new Date().toISOString()
  117. this.updatedAt = new Date().toISOString()
  118. }
  119. /**
  120. * Solving the violates foreign key constraint using cascade strategy
  121. * using static hooks
  122. * @see https://vincit.github.io/objection.js/api/types/#type-statichookarguments
  123. */
  124. static async beforeDelete({ asFindQuery }) {
  125. const page = await asFindQuery().select('id')
  126. await WIKI.models.comments.query().delete().where('pageId', page[0].id)
  127. }
  128. /**
  129. * Cache Schema
  130. */
  131. static get cacheSchema() {
  132. return new JSBinType({
  133. id: 'uint',
  134. authorId: 'uint',
  135. authorName: 'string',
  136. createdAt: 'string',
  137. creatorId: 'uint',
  138. creatorName: 'string',
  139. description: 'string',
  140. editorKey: 'string',
  141. isPrivate: 'boolean',
  142. isPublished: 'boolean',
  143. publishEndDate: 'string',
  144. publishStartDate: 'string',
  145. render: 'string',
  146. tags: [
  147. {
  148. tag: 'string',
  149. title: 'string'
  150. }
  151. ],
  152. extra: {
  153. js: 'string',
  154. css: 'string'
  155. },
  156. title: 'string',
  157. toc: 'string',
  158. minTocLevel: 'uint',
  159. tocLevel: 'uint',
  160. tocCollapseLevel: 'uint',
  161. doUseTocDefault: 'boolean',
  162. updatedAt: 'string'
  163. })
  164. }
  165. /**
  166. * Inject page metadata into contents
  167. *
  168. * @returns {string} Page Contents with Injected Metadata
  169. */
  170. injectMetadata () {
  171. return pageHelper.injectPageMetadata(this)
  172. }
  173. /**
  174. * Get the page's file extension based on content type
  175. *
  176. * @returns {string} File Extension
  177. */
  178. getFileExtension() {
  179. return pageHelper.getFileExtension(this.contentType)
  180. }
  181. /**
  182. * Parse injected page metadata from raw content
  183. *
  184. * @param {String} raw Raw file contents
  185. * @param {String} contentType Content Type
  186. * @returns {Object} Parsed Page Metadata with Raw Content
  187. */
  188. static parseMetadata (raw, contentType) {
  189. let result
  190. try {
  191. switch (contentType) {
  192. case 'markdown':
  193. result = frontmatterRegex.markdown.exec(raw)
  194. if (result[2]) {
  195. return {
  196. ...yaml.safeLoad(result[2]),
  197. content: result[3]
  198. }
  199. } else {
  200. // Attempt legacy v1 format
  201. result = frontmatterRegex.legacy.exec(raw)
  202. if (result[2]) {
  203. return {
  204. title: result[2],
  205. description: result[4],
  206. content: result[5]
  207. }
  208. }
  209. }
  210. break
  211. case 'html':
  212. result = frontmatterRegex.html.exec(raw)
  213. if (result[2]) {
  214. return {
  215. ...yaml.safeLoad(result[2]),
  216. content: result[3]
  217. }
  218. }
  219. break
  220. }
  221. } catch (err) {
  222. WIKI.logger.warn('Failed to parse page metadata. Invalid syntax.')
  223. }
  224. return {
  225. content: raw
  226. }
  227. }
  228. /**
  229. * Create a New Page
  230. *
  231. * @param {Object} opts Page Properties
  232. * @returns {Promise} Promise of the Page Model Instance
  233. */
  234. static async createPage(opts) {
  235. // -> Validate path
  236. if (opts.path.includes('.') || opts.path.includes(' ') || opts.path.includes('\\') || opts.path.includes('//')) {
  237. throw new WIKI.Error.PageIllegalPath()
  238. }
  239. // -> Remove trailing slash
  240. if (opts.path.endsWith('/')) {
  241. opts.path = opts.path.slice(0, -1)
  242. }
  243. // -> Remove starting slash
  244. if (opts.path.startsWith('/')) {
  245. opts.path = opts.path.slice(1)
  246. }
  247. // -> Check for page access
  248. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  249. locale: opts.locale,
  250. path: opts.path
  251. })) {
  252. throw new WIKI.Error.PageDeleteForbidden()
  253. }
  254. // -> Check for duplicate
  255. const dupCheck = await WIKI.models.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
  256. if (dupCheck) {
  257. throw new WIKI.Error.PageDuplicateCreate()
  258. }
  259. // -> Check for empty content
  260. if (!opts.content || _.trim(opts.content).length < 1) {
  261. throw new WIKI.Error.PageEmptyContent()
  262. }
  263. // -> Format CSS Scripts
  264. let scriptCss = ''
  265. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  266. locale: opts.locale,
  267. path: opts.path
  268. })) {
  269. if (!_.isEmpty(opts.scriptCss)) {
  270. scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
  271. } else {
  272. scriptCss = ''
  273. }
  274. }
  275. // -> Format JS Scripts
  276. let scriptJs = ''
  277. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  278. locale: opts.locale,
  279. path: opts.path
  280. })) {
  281. scriptJs = opts.scriptJs || ''
  282. }
  283. // -> Create page
  284. await WIKI.models.pages.query().insert({
  285. authorId: opts.user.id,
  286. content: opts.content,
  287. creatorId: opts.user.id,
  288. contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
  289. description: opts.description,
  290. editorKey: opts.editor,
  291. hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
  292. isPrivate: opts.isPrivate,
  293. isPublished: opts.isPublished,
  294. localeCode: opts.locale,
  295. path: opts.path,
  296. publishEndDate: opts.publishEndDate || '',
  297. publishStartDate: opts.publishStartDate || '',
  298. title: opts.title,
  299. toc: '[]',
  300. minTocLevel: opts.minTocLevel || 0,
  301. tocLevel: opts.tocLevel || 1,
  302. tocCollapseLevel: opts.tocCollapseLevel || 0,
  303. doUseTocDefault: opts.doUseTocDefault || true,
  304. extra: JSON.stringify({
  305. js: scriptJs,
  306. css: scriptCss
  307. })
  308. })
  309. const page = await WIKI.models.pages.getPageFromDb({
  310. path: opts.path,
  311. locale: opts.locale,
  312. userId: opts.user.id,
  313. isPrivate: opts.isPrivate
  314. })
  315. // -> Save Tags
  316. if (opts.tags && opts.tags.length > 0) {
  317. await WIKI.models.tags.associateTags({ tags: opts.tags, page })
  318. }
  319. // -> Render page to HTML
  320. await WIKI.models.pages.renderPage(page)
  321. // -> Rebuild page tree
  322. await WIKI.models.pages.rebuildTree()
  323. // -> Add to Search Index
  324. const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
  325. page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
  326. await WIKI.data.searchEngine.created(page)
  327. // -> Add to Storage
  328. if (!opts.skipStorage) {
  329. await WIKI.models.storage.pageEvent({
  330. event: 'created',
  331. page
  332. })
  333. }
  334. // -> Reconnect Links
  335. await WIKI.models.pages.reconnectLinks({
  336. locale: page.localeCode,
  337. path: page.path,
  338. mode: 'create'
  339. })
  340. // -> Get latest updatedAt
  341. page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  342. return page
  343. }
  344. /**
  345. * Update an Existing Page
  346. *
  347. * @param {Object} opts Page Properties
  348. * @returns {Promise} Promise of the Page Model Instance
  349. */
  350. static async updatePage(opts) {
  351. // -> Fetch original page
  352. const ogPage = await WIKI.models.pages.query().findById(opts.id)
  353. if (!ogPage) {
  354. throw new Error('Invalid Page Id')
  355. }
  356. // -> Check for page access
  357. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  358. locale: ogPage.localeCode,
  359. path: ogPage.path
  360. })) {
  361. throw new WIKI.Error.PageUpdateForbidden()
  362. }
  363. // -> Check for empty content
  364. if (!opts.content || _.trim(opts.content).length < 1) {
  365. throw new WIKI.Error.PageEmptyContent()
  366. }
  367. // -> Create version snapshot
  368. await WIKI.models.pageHistory.addVersion({
  369. ...ogPage,
  370. isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
  371. action: opts.action ? opts.action : 'updated',
  372. versionDate: ogPage.updatedAt
  373. })
  374. // -> Format Extra Properties
  375. if (!_.isPlainObject(ogPage.extra)) {
  376. ogPage.extra = {}
  377. }
  378. // -> Format CSS Scripts
  379. let scriptCss = _.get(ogPage, 'extra.css', '')
  380. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  381. locale: opts.locale,
  382. path: opts.path
  383. })) {
  384. if (!_.isEmpty(opts.scriptCss)) {
  385. scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
  386. } else {
  387. scriptCss = ''
  388. }
  389. }
  390. // -> Format JS Scripts
  391. let scriptJs = _.get(ogPage, 'extra.js', '')
  392. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  393. locale: opts.locale,
  394. path: opts.path
  395. })) {
  396. scriptJs = opts.scriptJs || ''
  397. }
  398. // -> Update page
  399. await WIKI.models.pages.query().patch({
  400. authorId: opts.user.id,
  401. content: opts.content,
  402. description: opts.description,
  403. isPublished: opts.isPublished === true || opts.isPublished === 1,
  404. publishEndDate: opts.publishEndDate || '',
  405. publishStartDate: opts.publishStartDate || '',
  406. title: opts.title,
  407. minTocLevel: opts.minTocLevel || 0,
  408. tocLevel: opts.tocLevel || 1,
  409. tocCollapseLevel: opts.tocCollapseLevel || 0,
  410. doUseTocDefault: opts.doUseTocDefault === true || opts.doUseTocDefault === 1,
  411. extra: JSON.stringify({
  412. ...ogPage.extra,
  413. js: scriptJs,
  414. css: scriptCss
  415. })
  416. }).where('id', ogPage.id)
  417. let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
  418. // -> Save Tags
  419. await WIKI.models.tags.associateTags({ tags: opts.tags, page })
  420. // -> Render page to HTML
  421. await WIKI.models.pages.renderPage(page)
  422. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  423. // -> Update Search Index
  424. const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
  425. page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
  426. await WIKI.data.searchEngine.updated(page)
  427. // -> Update on Storage
  428. if (!opts.skipStorage) {
  429. await WIKI.models.storage.pageEvent({
  430. event: 'updated',
  431. page
  432. })
  433. }
  434. // -> Perform move?
  435. if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
  436. // -> Check target path access
  437. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  438. locale: opts.locale,
  439. path: opts.path
  440. })) {
  441. throw new WIKI.Error.PageMoveForbidden()
  442. }
  443. await WIKI.models.pages.movePage({
  444. id: page.id,
  445. destinationLocale: opts.locale,
  446. destinationPath: opts.path,
  447. user: opts.user
  448. })
  449. } else {
  450. // -> Update title of page tree entry
  451. await WIKI.models.knex.table('pageTree').where({
  452. pageId: page.id
  453. }).update('title', page.title)
  454. }
  455. // -> Get latest updatedAt
  456. page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  457. return page
  458. }
  459. /**
  460. * Convert an Existing Page
  461. *
  462. * @param {Object} opts Page Properties
  463. * @returns {Promise} Promise of the Page Model Instance
  464. */
  465. static async convertPage(opts) {
  466. // -> Fetch original page
  467. const ogPage = await WIKI.models.pages.query().findById(opts.id)
  468. if (!ogPage) {
  469. throw new Error('Invalid Page Id')
  470. }
  471. if (ogPage.editorKey === opts.editor) {
  472. throw new Error('Page is already using this editor. Nothing to convert.')
  473. }
  474. // -> Check for page access
  475. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  476. locale: ogPage.localeCode,
  477. path: ogPage.path
  478. })) {
  479. throw new WIKI.Error.PageUpdateForbidden()
  480. }
  481. // -> Check content type
  482. const sourceContentType = ogPage.contentType
  483. const targetContentType = _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text')
  484. const shouldConvert = sourceContentType !== targetContentType
  485. let convertedContent = null
  486. // -> Convert content
  487. if (shouldConvert) {
  488. // -> Markdown => HTML
  489. if (sourceContentType === 'markdown' && targetContentType === 'html') {
  490. if (!ogPage.render) {
  491. throw new Error('Aborted conversion because rendered page content is empty!')
  492. }
  493. convertedContent = ogPage.render
  494. const $ = cheerio.load(convertedContent, {
  495. decodeEntities: true
  496. })
  497. if ($.root().children().length > 0) {
  498. // Remove header anchors
  499. $('.toc-anchor').remove()
  500. // Attempt to convert tabsets
  501. $('tabset').each((tabI, tabElm) => {
  502. const tabHeaders = []
  503. // -> Extract templates
  504. $(tabElm).children('template').each((tmplI, tmplElm) => {
  505. if ($(tmplElm).attr('v-slot:tabs') === '') {
  506. $(tabElm).before('<ul class="tabset-headers">' + $(tmplElm).html() + '</ul>')
  507. } else {
  508. $(tabElm).after('<div class="markdown-tabset">' + $(tmplElm).html() + '</div>')
  509. }
  510. })
  511. // -> Parse tab headers
  512. $(tabElm).prev('.tabset-headers').children((i, elm) => {
  513. tabHeaders.push($(elm).html())
  514. })
  515. $(tabElm).prev('.tabset-headers').remove()
  516. // -> Inject tab headers
  517. $(tabElm).next('.markdown-tabset').children((i, elm) => {
  518. if (tabHeaders.length > i) {
  519. $(elm).prepend(`<h2>${tabHeaders[i]}</h2>`)
  520. }
  521. })
  522. $(tabElm).next('.markdown-tabset').prepend('<h1>Tabset</h1>')
  523. $(tabElm).remove()
  524. })
  525. convertedContent = $.html('body').replace('<body>', '').replace('</body>', '').replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
  526. code = parseInt(code, 16)
  527. // Don't unescape ASCII characters, assuming they're encoded for a good reason
  528. if (code < 0x80) return entity
  529. return String.fromCodePoint(code)
  530. })
  531. }
  532. // -> HTML => Markdown
  533. } else if (sourceContentType === 'html' && targetContentType === 'markdown') {
  534. const td = new TurndownService({
  535. bulletListMarker: '-',
  536. codeBlockStyle: 'fenced',
  537. emDelimiter: '*',
  538. fence: '```',
  539. headingStyle: 'atx',
  540. hr: '---',
  541. linkStyle: 'inlined',
  542. preformattedCode: true,
  543. strongDelimiter: '**'
  544. })
  545. td.use(turndownPluginGfm)
  546. td.keep(['kbd'])
  547. td.addRule('subscript', {
  548. filter: ['sub'],
  549. replacement: c => `~${c}~`
  550. })
  551. td.addRule('superscript', {
  552. filter: ['sup'],
  553. replacement: c => `^${c}^`
  554. })
  555. td.addRule('underline', {
  556. filter: ['u'],
  557. replacement: c => `_${c}_`
  558. })
  559. td.addRule('taskList', {
  560. filter: (n, o) => {
  561. return n.nodeName === 'INPUT' && n.getAttribute('type') === 'checkbox'
  562. },
  563. replacement: (c, n) => {
  564. return n.getAttribute('checked') ? '[x] ' : '[ ] '
  565. }
  566. })
  567. td.addRule('removeTocAnchors', {
  568. filter: (n, o) => {
  569. return n.nodeName === 'A' && n.classList.contains('toc-anchor')
  570. },
  571. replacement: c => ''
  572. })
  573. convertedContent = td.turndown(ogPage.content)
  574. // -> Unsupported
  575. } else {
  576. throw new Error('Unsupported source / destination content types combination.')
  577. }
  578. }
  579. // -> Create version snapshot
  580. if (shouldConvert) {
  581. await WIKI.models.pageHistory.addVersion({
  582. ...ogPage,
  583. isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
  584. action: 'updated',
  585. versionDate: ogPage.updatedAt
  586. })
  587. }
  588. // -> Update page
  589. await WIKI.models.pages.query().patch({
  590. contentType: targetContentType,
  591. editorKey: opts.editor,
  592. ...(convertedContent ? { content: convertedContent } : {})
  593. }).where('id', ogPage.id)
  594. const page = await WIKI.models.pages.getPageFromDb(ogPage.id)
  595. await WIKI.models.pages.deletePageFromCache(page.hash)
  596. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  597. // -> Update on Storage
  598. await WIKI.models.storage.pageEvent({
  599. event: 'updated',
  600. page
  601. })
  602. }
  603. /**
  604. * Move a Page
  605. *
  606. * @param {Object} opts Page Properties
  607. * @returns {Promise} Promise with no value
  608. */
  609. static async movePage(opts) {
  610. let page
  611. if (_.has(opts, 'id')) {
  612. page = await WIKI.models.pages.query().findById(opts.id)
  613. } else {
  614. page = await WIKI.models.pages.query().findOne({
  615. path: opts.path,
  616. localeCode: opts.locale
  617. })
  618. }
  619. if (!page) {
  620. throw new WIKI.Error.PageNotFound()
  621. }
  622. // -> Validate path
  623. if (opts.destinationPath.includes('.') || opts.destinationPath.includes(' ') || opts.destinationPath.includes('\\') || opts.destinationPath.includes('//')) {
  624. throw new WIKI.Error.PageIllegalPath()
  625. }
  626. // -> Remove trailing slash
  627. if (opts.destinationPath.endsWith('/')) {
  628. opts.destinationPath = opts.destinationPath.slice(0, -1)
  629. }
  630. // -> Remove starting slash
  631. if (opts.destinationPath.startsWith('/')) {
  632. opts.destinationPath = opts.destinationPath.slice(1)
  633. }
  634. // -> Check for source page access
  635. if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
  636. locale: page.localeCode,
  637. path: page.path
  638. })) {
  639. throw new WIKI.Error.PageMoveForbidden()
  640. }
  641. // -> Check for destination page access
  642. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  643. locale: opts.destinationLocale,
  644. path: opts.destinationPath
  645. })) {
  646. throw new WIKI.Error.PageMoveForbidden()
  647. }
  648. // -> Check for existing page at destination path
  649. const destPage = await WIKI.models.pages.query().findOne({
  650. path: opts.destinationPath,
  651. localeCode: opts.destinationLocale
  652. })
  653. if (destPage) {
  654. throw new WIKI.Error.PagePathCollision()
  655. }
  656. // -> Create version snapshot
  657. await WIKI.models.pageHistory.addVersion({
  658. ...page,
  659. action: 'moved',
  660. versionDate: page.updatedAt
  661. })
  662. const destinationHash = pageHelper.generateHash({ path: opts.destinationPath, locale: opts.destinationLocale, privateNS: opts.isPrivate ? 'TODO' : '' })
  663. // -> Move page
  664. const destinationTitle = (page.title === page.path ? opts.destinationPath : page.title)
  665. await WIKI.models.pages.query().patch({
  666. path: opts.destinationPath,
  667. localeCode: opts.destinationLocale,
  668. title: destinationTitle,
  669. hash: destinationHash
  670. }).findById(page.id)
  671. await WIKI.models.pages.deletePageFromCache(page.hash)
  672. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  673. // -> Rebuild page tree
  674. await WIKI.models.pages.rebuildTree()
  675. // -> Rename in Search Index
  676. const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
  677. page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
  678. await WIKI.data.searchEngine.renamed({
  679. ...page,
  680. destinationPath: opts.destinationPath,
  681. destinationLocaleCode: opts.destinationLocale,
  682. destinationHash
  683. })
  684. // -> Rename in Storage
  685. if (!opts.skipStorage) {
  686. await WIKI.models.storage.pageEvent({
  687. event: 'renamed',
  688. page: {
  689. ...page,
  690. destinationPath: opts.destinationPath,
  691. destinationLocaleCode: opts.destinationLocale,
  692. destinationHash,
  693. moveAuthorId: opts.user.id,
  694. moveAuthorName: opts.user.name,
  695. moveAuthorEmail: opts.user.email
  696. }
  697. })
  698. }
  699. // -> Reconnect Links : Changing old links to the new path
  700. await WIKI.models.pages.reconnectLinks({
  701. sourceLocale: page.localeCode,
  702. sourcePath: page.path,
  703. locale: opts.destinationLocale,
  704. path: opts.destinationPath,
  705. mode: 'move'
  706. })
  707. // -> Reconnect Links : Validate invalid links to the new path
  708. await WIKI.models.pages.reconnectLinks({
  709. locale: opts.destinationLocale,
  710. path: opts.destinationPath,
  711. mode: 'create'
  712. })
  713. }
  714. /**
  715. * Delete an Existing Page
  716. *
  717. * @param {Object} opts Page Properties
  718. * @returns {Promise} Promise with no value
  719. */
  720. static async deletePage(opts) {
  721. const page = await WIKI.models.pages.getPageFromDb(_.has(opts, 'id') ? opts.id : opts);
  722. if (!page) {
  723. throw new WIKI.Error.PageNotFound()
  724. }
  725. // -> Check for page access
  726. if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
  727. locale: page.locale,
  728. path: page.path
  729. })) {
  730. throw new WIKI.Error.PageDeleteForbidden()
  731. }
  732. // -> Create version snapshot
  733. await WIKI.models.pageHistory.addVersion({
  734. ...page,
  735. action: 'deleted',
  736. versionDate: page.updatedAt
  737. })
  738. // -> Delete page
  739. await WIKI.models.pages.query().delete().where('id', page.id)
  740. await WIKI.models.pages.deletePageFromCache(page.hash)
  741. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  742. // -> Rebuild page tree
  743. await WIKI.models.pages.rebuildTree()
  744. // -> Delete from Search Index
  745. await WIKI.data.searchEngine.deleted(page)
  746. // -> Delete from Storage
  747. if (!opts.skipStorage) {
  748. await WIKI.models.storage.pageEvent({
  749. event: 'deleted',
  750. page
  751. })
  752. }
  753. // -> Reconnect Links
  754. await WIKI.models.pages.reconnectLinks({
  755. locale: page.localeCode,
  756. path: page.path,
  757. mode: 'delete'
  758. })
  759. }
  760. /**
  761. * Reconnect links to new/move/deleted page
  762. *
  763. * @param {Object} opts - Page parameters
  764. * @param {string} opts.path - Page Path
  765. * @param {string} opts.locale - Page Locale Code
  766. * @param {string} [opts.sourcePath] - Previous Page Path (move only)
  767. * @param {string} [opts.sourceLocale] - Previous Page Locale Code (move only)
  768. * @param {string} opts.mode - Page Update mode (create, move, delete)
  769. * @returns {Promise} Promise with no value
  770. */
  771. static async reconnectLinks (opts) {
  772. const pageHref = `/${opts.locale}/${opts.path}`
  773. let replaceArgs = {
  774. from: '',
  775. to: ''
  776. }
  777. switch (opts.mode) {
  778. case 'create':
  779. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  780. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  781. break
  782. case 'move':
  783. const prevPageHref = `/${opts.sourceLocale}/${opts.sourcePath}`
  784. replaceArgs.from = `<a href="${prevPageHref}" class="is-internal-link is-valid-page">`
  785. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  786. break
  787. case 'delete':
  788. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  789. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  790. break
  791. default:
  792. return false
  793. }
  794. let affectedHashes = []
  795. // -> Perform replace and return affected page hashes (POSTGRES only)
  796. if (WIKI.config.db.type === 'postgres') {
  797. const qryHashes = await WIKI.models.pages.query()
  798. .returning('hash')
  799. .patch({
  800. render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  801. })
  802. .whereIn('pages.id', function () {
  803. this.select('pageLinks.pageId').from('pageLinks').where({
  804. 'pageLinks.path': opts.path,
  805. 'pageLinks.localeCode': opts.locale
  806. })
  807. })
  808. affectedHashes = qryHashes.map(h => h.hash)
  809. } else {
  810. // -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
  811. await WIKI.models.pages.query()
  812. .patch({
  813. render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  814. })
  815. .whereIn('pages.id', function () {
  816. this.select('pageLinks.pageId').from('pageLinks').where({
  817. 'pageLinks.path': opts.path,
  818. 'pageLinks.localeCode': opts.locale
  819. })
  820. })
  821. const qryHashes = await WIKI.models.pages.query()
  822. .column('hash')
  823. .whereIn('pages.id', function () {
  824. this.select('pageLinks.pageId').from('pageLinks').where({
  825. 'pageLinks.path': opts.path,
  826. 'pageLinks.localeCode': opts.locale
  827. })
  828. })
  829. affectedHashes = qryHashes.map(h => h.hash)
  830. }
  831. for (const hash of affectedHashes) {
  832. await WIKI.models.pages.deletePageFromCache(hash)
  833. WIKI.events.outbound.emit('deletePageFromCache', hash)
  834. }
  835. }
  836. /**
  837. * Rebuild page tree for new/updated/deleted page
  838. *
  839. * @returns {Promise} Promise with no value
  840. */
  841. static async rebuildTree() {
  842. const rebuildJob = await WIKI.scheduler.registerJob({
  843. name: 'rebuild-tree',
  844. immediate: true,
  845. worker: true
  846. })
  847. return rebuildJob.finished
  848. }
  849. /**
  850. * Trigger the rendering of a page
  851. *
  852. * @param {Object} page Page Model Instance
  853. * @returns {Promise} Promise with no value
  854. */
  855. static async renderPage(page) {
  856. const renderJob = await WIKI.scheduler.registerJob({
  857. name: 'render-page',
  858. immediate: true,
  859. worker: true
  860. }, page.id)
  861. return renderJob.finished
  862. }
  863. /**
  864. * Fetch an Existing Page from Cache if possible, from DB otherwise and save render to Cache
  865. *
  866. * @param {Object} opts Page Properties
  867. * @returns {Promise} Promise of the Page Model Instance
  868. */
  869. static async getPage(opts) {
  870. // -> Get from cache first
  871. let page = await WIKI.models.pages.getPageFromCache(opts)
  872. if (!page) {
  873. // -> Get from DB
  874. page = await WIKI.models.pages.getPageFromDb(opts)
  875. if (page) {
  876. if (page.render) {
  877. // -> Save render to cache
  878. await WIKI.models.pages.savePageToCache(page)
  879. } else {
  880. // -> No render? Possible duplicate issue
  881. /* TODO: Detect duplicate and delete */
  882. throw new Error('Error while fetching page. Duplicate entry detected. Reload the page to try again.')
  883. }
  884. }
  885. }
  886. return page
  887. }
  888. /**
  889. * Fetch an Existing Page from the Database
  890. *
  891. * @param {Object} opts Page Properties
  892. * @returns {Promise} Promise of the Page Model Instance
  893. */
  894. static async getPageFromDb(opts) {
  895. const queryModeID = _.isNumber(opts)
  896. try {
  897. return WIKI.models.pages.query()
  898. .column([
  899. 'pages.id',
  900. 'pages.path',
  901. 'pages.hash',
  902. 'pages.title',
  903. 'pages.description',
  904. 'pages.isPrivate',
  905. 'pages.isPublished',
  906. 'pages.privateNS',
  907. 'pages.publishStartDate',
  908. 'pages.publishEndDate',
  909. 'pages.content',
  910. 'pages.render',
  911. 'pages.toc',
  912. 'pages.minTocLevel',
  913. 'pages.tocLevel',
  914. 'pages.tocCollapseLevel',
  915. 'pages.doUseTocDefault',
  916. 'pages.contentType',
  917. 'pages.createdAt',
  918. 'pages.updatedAt',
  919. 'pages.editorKey',
  920. 'pages.localeCode',
  921. 'pages.authorId',
  922. 'pages.creatorId',
  923. 'pages.extra',
  924. {
  925. authorName: 'author.name',
  926. authorEmail: 'author.email',
  927. creatorName: 'creator.name',
  928. creatorEmail: 'creator.email'
  929. }
  930. ])
  931. .joinRelated('author')
  932. .joinRelated('creator')
  933. .withGraphJoined('tags')
  934. .modifyGraph('tags', builder => {
  935. builder.select('tag', 'title')
  936. })
  937. .where(queryModeID ? {
  938. 'pages.id': opts
  939. } : {
  940. 'pages.path': opts.path,
  941. 'pages.localeCode': opts.locale
  942. })
  943. // .andWhere(builder => {
  944. // if (queryModeID) return
  945. // builder.where({
  946. // 'pages.isPublished': true
  947. // }).orWhere({
  948. // 'pages.isPublished': false,
  949. // 'pages.authorId': opts.userId
  950. // })
  951. // })
  952. // .andWhere(builder => {
  953. // if (queryModeID) return
  954. // if (opts.isPrivate) {
  955. // builder.where({ 'pages.isPrivate': true, 'pages.privateNS': opts.privateNS })
  956. // } else {
  957. // builder.where({ 'pages.isPrivate': false })
  958. // }
  959. // })
  960. .first()
  961. } catch (err) {
  962. WIKI.logger.warn(err)
  963. throw err
  964. }
  965. }
  966. /**
  967. * Save a Page Model Instance to Cache
  968. *
  969. * @param {Object} page Page Model Instance
  970. * @returns {Promise} Promise with no value
  971. */
  972. static async savePageToCache(page) {
  973. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
  974. await fs.outputFile(cachePath, WIKI.models.pages.cacheSchema.encode({
  975. id: page.id,
  976. authorId: page.authorId,
  977. authorName: page.authorName,
  978. createdAt: page.createdAt,
  979. creatorId: page.creatorId,
  980. creatorName: page.creatorName,
  981. description: page.description,
  982. editorKey: page.editorKey,
  983. extra: {
  984. css: _.get(page, 'extra.css', ''),
  985. js: _.get(page, 'extra.js', '')
  986. },
  987. isPrivate: page.isPrivate === 1 || page.isPrivate === true,
  988. isPublished: page.isPublished === 1 || page.isPublished === true,
  989. publishEndDate: page.publishEndDate,
  990. publishStartDate: page.publishStartDate,
  991. render: page.render,
  992. tags: page.tags.map(t => _.pick(t, ['tag', 'title'])),
  993. title: page.title,
  994. toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
  995. minTocLevel: page.minTocLevel,
  996. tocLevel: page.tocLevel,
  997. tocCollapseLevel: page.tocCollapseLevel,
  998. doUseTocDefault: page.doUseTocDefault,
  999. updatedAt: page.updatedAt
  1000. }))
  1001. }
  1002. /**
  1003. * Fetch an Existing Page from Cache
  1004. *
  1005. * @param {Object} opts Page Properties
  1006. * @returns {Promise} Promise of the Page Model Instance
  1007. */
  1008. static async getPageFromCache(opts) {
  1009. const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' })
  1010. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
  1011. try {
  1012. const pageBuffer = await fs.readFile(cachePath)
  1013. let page = WIKI.models.pages.cacheSchema.decode(pageBuffer)
  1014. return {
  1015. ...page,
  1016. path: opts.path,
  1017. localeCode: opts.locale,
  1018. isPrivate: opts.isPrivate
  1019. }
  1020. } catch (err) {
  1021. if (err.code === 'ENOENT') {
  1022. return false
  1023. }
  1024. WIKI.logger.error(err)
  1025. throw err
  1026. }
  1027. }
  1028. /**
  1029. * Delete an Existing Page from Cache
  1030. *
  1031. * @param {String} page Page Unique Hash
  1032. * @returns {Promise} Promise with no value
  1033. */
  1034. static async deletePageFromCache(hash) {
  1035. return fs.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
  1036. }
  1037. /**
  1038. * Flush the contents of the Cache
  1039. */
  1040. static async flushCache() {
  1041. return fs.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
  1042. }
  1043. /**
  1044. * Migrate all pages from a source locale to the target locale
  1045. *
  1046. * @param {Object} opts Migration properties
  1047. * @param {string} opts.sourceLocale Source Locale Code
  1048. * @param {string} opts.targetLocale Target Locale Code
  1049. * @returns {Promise} Promise with no value
  1050. */
  1051. static async migrateToLocale({ sourceLocale, targetLocale }) {
  1052. return WIKI.models.pages.query()
  1053. .patch({
  1054. localeCode: targetLocale
  1055. })
  1056. .where({
  1057. localeCode: sourceLocale
  1058. })
  1059. .whereNotExists(function() {
  1060. this.select('id').from('pages AS pagesm').where('pagesm.localeCode', targetLocale).andWhereRaw('pagesm.path = pages.path')
  1061. })
  1062. }
  1063. /**
  1064. * Clean raw HTML from content for use in search engines
  1065. *
  1066. * @param {string} rawHTML Raw HTML
  1067. * @returns {string} Cleaned Content Text
  1068. */
  1069. static cleanHTML(rawHTML = '') {
  1070. let data = striptags(rawHTML || '', [], ' ')
  1071. .replace(emojiRegex(), '')
  1072. // .replace(htmlEntitiesRegex, '')
  1073. return he.decode(data)
  1074. .replace(punctuationRegex, ' ')
  1075. .replace(/(\r\n|\n|\r)/gm, ' ')
  1076. .replace(/\s\s+/g, ' ')
  1077. .split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
  1078. }
  1079. /**
  1080. * Subscribe to HA propagation events
  1081. */
  1082. static subscribeToEvents() {
  1083. WIKI.events.inbound.on('deletePageFromCache', hash => {
  1084. WIKI.models.pages.deletePageFromCache(hash)
  1085. })
  1086. WIKI.events.inbound.on('flushCache', () => {
  1087. WIKI.models.pages.flushCache()
  1088. })
  1089. }
  1090. }