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.

968 lines
27 KiB

6 years ago
6 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
  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. /* global WIKI */
  13. const frontmatterRegex = {
  14. html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
  15. legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
  16. markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
  17. }
  18. const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
  19. // const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
  20. /**
  21. * Pages model
  22. */
  23. module.exports = class Page extends Model {
  24. static get tableName() { return 'pages' }
  25. static get jsonSchema () {
  26. return {
  27. type: 'object',
  28. required: ['path', 'title'],
  29. properties: {
  30. id: {type: 'integer'},
  31. path: {type: 'string'},
  32. hash: {type: 'string'},
  33. title: {type: 'string'},
  34. description: {type: 'string'},
  35. isPublished: {type: 'boolean'},
  36. privateNS: {type: 'string'},
  37. publishStartDate: {type: 'string'},
  38. publishEndDate: {type: 'string'},
  39. content: {type: 'string'},
  40. contentType: {type: 'string'},
  41. createdAt: {type: 'string'},
  42. updatedAt: {type: 'string'}
  43. }
  44. }
  45. }
  46. static get jsonAttributes() {
  47. return ['extra']
  48. }
  49. static get relationMappings() {
  50. return {
  51. tags: {
  52. relation: Model.ManyToManyRelation,
  53. modelClass: require('./tags'),
  54. join: {
  55. from: 'pages.id',
  56. through: {
  57. from: 'pageTags.pageId',
  58. to: 'pageTags.tagId'
  59. },
  60. to: 'tags.id'
  61. }
  62. },
  63. links: {
  64. relation: Model.HasManyRelation,
  65. modelClass: require('./pageLinks'),
  66. join: {
  67. from: 'pages.id',
  68. to: 'pageLinks.pageId'
  69. }
  70. },
  71. author: {
  72. relation: Model.BelongsToOneRelation,
  73. modelClass: require('./users'),
  74. join: {
  75. from: 'pages.authorId',
  76. to: 'users.id'
  77. }
  78. },
  79. creator: {
  80. relation: Model.BelongsToOneRelation,
  81. modelClass: require('./users'),
  82. join: {
  83. from: 'pages.creatorId',
  84. to: 'users.id'
  85. }
  86. },
  87. editor: {
  88. relation: Model.BelongsToOneRelation,
  89. modelClass: require('./editors'),
  90. join: {
  91. from: 'pages.editorKey',
  92. to: 'editors.key'
  93. }
  94. },
  95. locale: {
  96. relation: Model.BelongsToOneRelation,
  97. modelClass: require('./locales'),
  98. join: {
  99. from: 'pages.localeCode',
  100. to: 'locales.code'
  101. }
  102. }
  103. }
  104. }
  105. $beforeUpdate() {
  106. this.updatedAt = new Date().toISOString()
  107. }
  108. $beforeInsert() {
  109. this.createdAt = new Date().toISOString()
  110. this.updatedAt = new Date().toISOString()
  111. }
  112. /**
  113. * Cache Schema
  114. */
  115. static get cacheSchema() {
  116. return new JSBinType({
  117. id: 'uint',
  118. authorId: 'uint',
  119. authorName: 'string',
  120. createdAt: 'string',
  121. creatorId: 'uint',
  122. creatorName: 'string',
  123. description: 'string',
  124. isPrivate: 'boolean',
  125. isPublished: 'boolean',
  126. publishEndDate: 'string',
  127. publishStartDate: 'string',
  128. render: 'string',
  129. tags: [
  130. {
  131. tag: 'string',
  132. title: 'string'
  133. }
  134. ],
  135. extra: {
  136. js: 'string',
  137. css: 'string'
  138. },
  139. title: 'string',
  140. toc: 'string',
  141. updatedAt: 'string'
  142. })
  143. }
  144. /**
  145. * Inject page metadata into contents
  146. *
  147. * @returns {string} Page Contents with Injected Metadata
  148. */
  149. injectMetadata () {
  150. return pageHelper.injectPageMetadata(this)
  151. }
  152. /**
  153. * Get the page's file extension based on content type
  154. *
  155. * @returns {string} File Extension
  156. */
  157. getFileExtension() {
  158. return pageHelper.getFileExtension(this.contentType)
  159. }
  160. /**
  161. * Parse injected page metadata from raw content
  162. *
  163. * @param {String} raw Raw file contents
  164. * @param {String} contentType Content Type
  165. * @returns {Object} Parsed Page Metadata with Raw Content
  166. */
  167. static parseMetadata (raw, contentType) {
  168. let result
  169. switch (contentType) {
  170. case 'markdown':
  171. result = frontmatterRegex.markdown.exec(raw)
  172. if (result[2]) {
  173. return {
  174. ...yaml.safeLoad(result[2]),
  175. content: result[3]
  176. }
  177. } else {
  178. // Attempt legacy v1 format
  179. result = frontmatterRegex.legacy.exec(raw)
  180. if (result[2]) {
  181. return {
  182. title: result[2],
  183. description: result[4],
  184. content: result[5]
  185. }
  186. }
  187. }
  188. break
  189. case 'html':
  190. result = frontmatterRegex.html.exec(raw)
  191. if (result[2]) {
  192. return {
  193. ...yaml.safeLoad(result[2]),
  194. content: result[3]
  195. }
  196. }
  197. break
  198. }
  199. return {
  200. content: raw
  201. }
  202. }
  203. /**
  204. * Create a New Page
  205. *
  206. * @param {Object} opts Page Properties
  207. * @returns {Promise} Promise of the Page Model Instance
  208. */
  209. static async createPage(opts) {
  210. // -> Validate path
  211. if (opts.path.indexOf('.') >= 0 || opts.path.indexOf(' ') >= 0 || opts.path.indexOf('\\') >= 0 || opts.path.indexOf('//') >= 0) {
  212. throw new WIKI.Error.PageIllegalPath()
  213. }
  214. // -> Remove trailing slash
  215. if (opts.path.endsWith('/')) {
  216. opts.path = opts.path.slice(0, -1)
  217. }
  218. // -> Remove starting slash
  219. if (opts.path.startsWith('/')) {
  220. opts.path = opts.path.slice(1)
  221. }
  222. // -> Check for page access
  223. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  224. locale: opts.locale,
  225. path: opts.path
  226. })) {
  227. throw new WIKI.Error.PageDeleteForbidden()
  228. }
  229. // -> Check for duplicate
  230. const dupCheck = await WIKI.models.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
  231. if (dupCheck) {
  232. throw new WIKI.Error.PageDuplicateCreate()
  233. }
  234. // -> Check for empty content
  235. if (!opts.content || _.trim(opts.content).length < 1) {
  236. throw new WIKI.Error.PageEmptyContent()
  237. }
  238. // -> Format CSS Scripts
  239. let scriptCss = ''
  240. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  241. locale: opts.locale,
  242. path: opts.path
  243. })) {
  244. if (!_.isEmpty(opts.scriptCss)) {
  245. scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
  246. } else {
  247. scriptCss = ''
  248. }
  249. }
  250. // -> Format JS Scripts
  251. let scriptJs = ''
  252. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  253. locale: opts.locale,
  254. path: opts.path
  255. })) {
  256. scriptJs = opts.scriptJs || ''
  257. }
  258. // -> Create page
  259. await WIKI.models.pages.query().insert({
  260. authorId: opts.user.id,
  261. content: opts.content,
  262. creatorId: opts.user.id,
  263. contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
  264. description: opts.description,
  265. editorKey: opts.editor,
  266. hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
  267. isPrivate: opts.isPrivate,
  268. isPublished: opts.isPublished,
  269. localeCode: opts.locale,
  270. path: opts.path,
  271. publishEndDate: opts.publishEndDate || '',
  272. publishStartDate: opts.publishStartDate || '',
  273. title: opts.title,
  274. toc: '[]',
  275. extra: JSON.stringify({
  276. js: scriptJs,
  277. css: scriptCss
  278. })
  279. })
  280. const page = await WIKI.models.pages.getPageFromDb({
  281. path: opts.path,
  282. locale: opts.locale,
  283. userId: opts.user.id,
  284. isPrivate: opts.isPrivate
  285. })
  286. // -> Save Tags
  287. if (opts.tags && opts.tags.length > 0) {
  288. await WIKI.models.tags.associateTags({ tags: opts.tags, page })
  289. }
  290. // -> Render page to HTML
  291. await WIKI.models.pages.renderPage(page)
  292. // -> Rebuild page tree
  293. await WIKI.models.pages.rebuildTree()
  294. // -> Add to Search Index
  295. const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
  296. page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
  297. await WIKI.data.searchEngine.created(page)
  298. // -> Add to Storage
  299. if (!opts.skipStorage) {
  300. await WIKI.models.storage.pageEvent({
  301. event: 'created',
  302. page
  303. })
  304. }
  305. // -> Reconnect Links
  306. await WIKI.models.pages.reconnectLinks({
  307. locale: page.localeCode,
  308. path: page.path,
  309. mode: 'create'
  310. })
  311. // -> Get latest updatedAt
  312. page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  313. return page
  314. }
  315. /**
  316. * Update an Existing Page
  317. *
  318. * @param {Object} opts Page Properties
  319. * @returns {Promise} Promise of the Page Model Instance
  320. */
  321. static async updatePage(opts) {
  322. // -> Fetch original page
  323. const ogPage = await WIKI.models.pages.query().findById(opts.id)
  324. if (!ogPage) {
  325. throw new Error('Invalid Page Id')
  326. }
  327. // -> Check for page access
  328. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  329. locale: opts.locale,
  330. path: opts.path
  331. })) {
  332. throw new WIKI.Error.PageUpdateForbidden()
  333. }
  334. // -> Check for empty content
  335. if (!opts.content || _.trim(opts.content).length < 1) {
  336. throw new WIKI.Error.PageEmptyContent()
  337. }
  338. // -> Create version snapshot
  339. await WIKI.models.pageHistory.addVersion({
  340. ...ogPage,
  341. isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
  342. action: opts.action ? opts.action : 'updated',
  343. versionDate: ogPage.updatedAt
  344. })
  345. // -> Format Extra Properties
  346. if (!_.isPlainObject(ogPage.extra)) {
  347. ogPage.extra = {}
  348. }
  349. // -> Format CSS Scripts
  350. let scriptCss = _.get(ogPage, 'extra.css', '')
  351. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  352. locale: opts.locale,
  353. path: opts.path
  354. })) {
  355. if (!_.isEmpty(opts.scriptCss)) {
  356. scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
  357. } else {
  358. scriptCss = ''
  359. }
  360. }
  361. // -> Format JS Scripts
  362. let scriptJs = _.get(ogPage, 'extra.js', '')
  363. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  364. locale: opts.locale,
  365. path: opts.path
  366. })) {
  367. scriptJs = opts.scriptJs || ''
  368. }
  369. // -> Update page
  370. await WIKI.models.pages.query().patch({
  371. authorId: opts.user.id,
  372. content: opts.content,
  373. description: opts.description,
  374. isPublished: opts.isPublished === true || opts.isPublished === 1,
  375. publishEndDate: opts.publishEndDate || '',
  376. publishStartDate: opts.publishStartDate || '',
  377. title: opts.title,
  378. extra: JSON.stringify({
  379. ...ogPage.extra,
  380. js: scriptJs,
  381. css: scriptCss
  382. })
  383. }).where('id', ogPage.id)
  384. let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
  385. // -> Save Tags
  386. await WIKI.models.tags.associateTags({ tags: opts.tags, page })
  387. // -> Render page to HTML
  388. await WIKI.models.pages.renderPage(page)
  389. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  390. // -> Update Search Index
  391. const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
  392. page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
  393. await WIKI.data.searchEngine.updated(page)
  394. // -> Update on Storage
  395. if (!opts.skipStorage) {
  396. await WIKI.models.storage.pageEvent({
  397. event: 'updated',
  398. page
  399. })
  400. }
  401. // -> Perform move?
  402. if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
  403. await WIKI.models.pages.movePage({
  404. id: page.id,
  405. destinationLocale: opts.locale,
  406. destinationPath: opts.path,
  407. user: opts.user
  408. })
  409. } else {
  410. // -> Update title of page tree entry
  411. await WIKI.models.knex.table('pageTree').where({
  412. pageId: page.id
  413. }).update('title', page.title)
  414. }
  415. // -> Get latest updatedAt
  416. page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  417. return page
  418. }
  419. /**
  420. * Move a Page
  421. *
  422. * @param {Object} opts Page Properties
  423. * @returns {Promise} Promise with no value
  424. */
  425. static async movePage(opts) {
  426. const page = await WIKI.models.pages.query().findById(opts.id)
  427. if (!page) {
  428. throw new WIKI.Error.PageNotFound()
  429. }
  430. // -> Validate path
  431. if (opts.destinationPath.indexOf('.') >= 0 || opts.destinationPath.indexOf(' ') >= 0 || opts.destinationPath.indexOf('\\') >= 0 || opts.destinationPath.indexOf('//') >= 0) {
  432. throw new WIKI.Error.PageIllegalPath()
  433. }
  434. // -> Remove trailing slash
  435. if (opts.destinationPath.endsWith('/')) {
  436. opts.destinationPath = opts.destinationPath.slice(0, -1)
  437. }
  438. // -> Remove starting slash
  439. if (opts.destinationPath.startsWith('/')) {
  440. opts.destinationPath = opts.destinationPath.slice(1)
  441. }
  442. // -> Check for source page access
  443. if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
  444. locale: page.localeCode,
  445. path: page.path
  446. })) {
  447. throw new WIKI.Error.PageMoveForbidden()
  448. }
  449. // -> Check for destination page access
  450. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  451. locale: opts.destinationLocale,
  452. path: opts.destinationPath
  453. })) {
  454. throw new WIKI.Error.PageMoveForbidden()
  455. }
  456. // -> Check for existing page at destination path
  457. const destPage = await await WIKI.models.pages.query().findOne({
  458. path: opts.destinationPath,
  459. localeCode: opts.destinationLocale
  460. })
  461. if (destPage) {
  462. throw new WIKI.Error.PagePathCollision()
  463. }
  464. // -> Create version snapshot
  465. await WIKI.models.pageHistory.addVersion({
  466. ...page,
  467. action: 'moved',
  468. versionDate: page.updatedAt
  469. })
  470. const destinationHash = pageHelper.generateHash({ path: opts.destinationPath, locale: opts.destinationLocale, privateNS: opts.isPrivate ? 'TODO' : '' })
  471. // -> Move page
  472. await WIKI.models.pages.query().patch({
  473. path: opts.destinationPath,
  474. localeCode: opts.destinationLocale,
  475. hash: destinationHash
  476. }).findById(page.id)
  477. await WIKI.models.pages.deletePageFromCache(page.hash)
  478. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  479. // -> Rebuild page tree
  480. await WIKI.models.pages.rebuildTree()
  481. // -> Rename in Search Index
  482. await WIKI.data.searchEngine.renamed({
  483. ...page,
  484. destinationPath: opts.destinationPath,
  485. destinationLocaleCode: opts.destinationLocale,
  486. destinationHash
  487. })
  488. // -> Rename in Storage
  489. if (!opts.skipStorage) {
  490. await WIKI.models.storage.pageEvent({
  491. event: 'renamed',
  492. page: {
  493. ...page,
  494. destinationPath: opts.destinationPath,
  495. destinationLocaleCode: opts.destinationLocale,
  496. destinationHash,
  497. moveAuthorId: opts.user.id,
  498. moveAuthorName: opts.user.name,
  499. moveAuthorEmail: opts.user.email
  500. }
  501. })
  502. }
  503. // -> Reconnect Links
  504. await WIKI.models.pages.reconnectLinks({
  505. sourceLocale: page.localeCode,
  506. sourcePath: page.path,
  507. locale: opts.destinationLocale,
  508. path: opts.destinationPath,
  509. mode: 'move'
  510. })
  511. }
  512. /**
  513. * Delete an Existing Page
  514. *
  515. * @param {Object} opts Page Properties
  516. * @returns {Promise} Promise with no value
  517. */
  518. static async deletePage(opts) {
  519. let page
  520. if (_.has(opts, 'id')) {
  521. page = await WIKI.models.pages.query().findById(opts.id)
  522. } else {
  523. page = await await WIKI.models.pages.query().findOne({
  524. path: opts.path,
  525. localeCode: opts.locale
  526. })
  527. }
  528. if (!page) {
  529. throw new Error('Invalid Page Id')
  530. }
  531. // -> Check for page access
  532. if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
  533. locale: page.locale,
  534. path: page.path
  535. })) {
  536. throw new WIKI.Error.PageDeleteForbidden()
  537. }
  538. // -> Create version snapshot
  539. await WIKI.models.pageHistory.addVersion({
  540. ...page,
  541. action: 'deleted',
  542. versionDate: page.updatedAt
  543. })
  544. // -> Delete page
  545. await WIKI.models.pages.query().delete().where('id', page.id)
  546. await WIKI.models.pages.deletePageFromCache(page.hash)
  547. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  548. // -> Rebuild page tree
  549. await WIKI.models.pages.rebuildTree()
  550. // -> Delete from Search Index
  551. await WIKI.data.searchEngine.deleted(page)
  552. // -> Delete from Storage
  553. if (!opts.skipStorage) {
  554. await WIKI.models.storage.pageEvent({
  555. event: 'deleted',
  556. page
  557. })
  558. }
  559. // -> Reconnect Links
  560. await WIKI.models.pages.reconnectLinks({
  561. locale: page.localeCode,
  562. path: page.path,
  563. mode: 'delete'
  564. })
  565. }
  566. /**
  567. * Reconnect links to new/move/deleted page
  568. *
  569. * @param {Object} opts - Page parameters
  570. * @param {string} opts.path - Page Path
  571. * @param {string} opts.locale - Page Locale Code
  572. * @param {string} [opts.sourcePath] - Previous Page Path (move only)
  573. * @param {string} [opts.sourceLocale] - Previous Page Locale Code (move only)
  574. * @param {string} opts.mode - Page Update mode (create, move, delete)
  575. * @returns {Promise} Promise with no value
  576. */
  577. static async reconnectLinks (opts) {
  578. const pageHref = `/${opts.locale}/${opts.path}`
  579. let replaceArgs = {
  580. from: '',
  581. to: ''
  582. }
  583. switch (opts.mode) {
  584. case 'create':
  585. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  586. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  587. break
  588. case 'move':
  589. const prevPageHref = `/${opts.sourceLocale}/${opts.sourcePath}`
  590. replaceArgs.from = `<a href="${prevPageHref}" class="is-internal-link is-invalid-page">`
  591. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  592. break
  593. case 'delete':
  594. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  595. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  596. break
  597. default:
  598. return false
  599. }
  600. let affectedHashes = []
  601. // -> Perform replace and return affected page hashes (POSTGRES only)
  602. if (WIKI.config.db.type === 'postgres') {
  603. const qryHashes = await WIKI.models.pages.query()
  604. .returning('hash')
  605. .patch({
  606. render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  607. })
  608. .whereIn('pages.id', function () {
  609. this.select('pageLinks.pageId').from('pageLinks').where({
  610. 'pageLinks.path': opts.path,
  611. 'pageLinks.localeCode': opts.locale
  612. })
  613. })
  614. affectedHashes = qryHashes.map(h => h.hash)
  615. } else {
  616. // -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
  617. await WIKI.models.pages.query()
  618. .patch({
  619. render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  620. })
  621. .whereIn('pages.id', function () {
  622. this.select('pageLinks.pageId').from('pageLinks').where({
  623. 'pageLinks.path': opts.path,
  624. 'pageLinks.localeCode': opts.locale
  625. })
  626. })
  627. const qryHashes = await WIKI.models.pages.query()
  628. .column('hash')
  629. .whereIn('pages.id', function () {
  630. this.select('pageLinks.pageId').from('pageLinks').where({
  631. 'pageLinks.path': opts.path,
  632. 'pageLinks.localeCode': opts.locale
  633. })
  634. })
  635. affectedHashes = qryHashes.map(h => h.hash)
  636. }
  637. for (const hash of affectedHashes) {
  638. await WIKI.models.pages.deletePageFromCache(hash)
  639. WIKI.events.outbound.emit('deletePageFromCache', hash)
  640. }
  641. }
  642. /**
  643. * Rebuild page tree for new/updated/deleted page
  644. *
  645. * @returns {Promise} Promise with no value
  646. */
  647. static async rebuildTree() {
  648. const rebuildJob = await WIKI.scheduler.registerJob({
  649. name: 'rebuild-tree',
  650. immediate: true,
  651. worker: true
  652. })
  653. return rebuildJob.finished
  654. }
  655. /**
  656. * Trigger the rendering of a page
  657. *
  658. * @param {Object} page Page Model Instance
  659. * @returns {Promise} Promise with no value
  660. */
  661. static async renderPage(page) {
  662. const renderJob = await WIKI.scheduler.registerJob({
  663. name: 'render-page',
  664. immediate: true,
  665. worker: true
  666. }, page.id)
  667. return renderJob.finished
  668. }
  669. /**
  670. * Fetch an Existing Page from Cache if possible, from DB otherwise and save render to Cache
  671. *
  672. * @param {Object} opts Page Properties
  673. * @returns {Promise} Promise of the Page Model Instance
  674. */
  675. static async getPage(opts) {
  676. // -> Get from cache first
  677. let page = await WIKI.models.pages.getPageFromCache(opts)
  678. if (!page) {
  679. // -> Get from DB
  680. page = await WIKI.models.pages.getPageFromDb(opts)
  681. if (page) {
  682. if (page.render) {
  683. // -> Save render to cache
  684. await WIKI.models.pages.savePageToCache(page)
  685. } else {
  686. // -> No render? Possible duplicate issue
  687. /* TODO: Detect duplicate and delete */
  688. throw new Error('Error while fetching page. Duplicate entry detected. Reload the page to try again.')
  689. }
  690. }
  691. }
  692. return page
  693. }
  694. /**
  695. * Fetch an Existing Page from the Database
  696. *
  697. * @param {Object} opts Page Properties
  698. * @returns {Promise} Promise of the Page Model Instance
  699. */
  700. static async getPageFromDb(opts) {
  701. const queryModeID = _.isNumber(opts)
  702. try {
  703. return WIKI.models.pages.query()
  704. .column([
  705. 'pages.id',
  706. 'pages.path',
  707. 'pages.hash',
  708. 'pages.title',
  709. 'pages.description',
  710. 'pages.isPrivate',
  711. 'pages.isPublished',
  712. 'pages.privateNS',
  713. 'pages.publishStartDate',
  714. 'pages.publishEndDate',
  715. 'pages.content',
  716. 'pages.render',
  717. 'pages.toc',
  718. 'pages.contentType',
  719. 'pages.createdAt',
  720. 'pages.updatedAt',
  721. 'pages.editorKey',
  722. 'pages.localeCode',
  723. 'pages.authorId',
  724. 'pages.creatorId',
  725. 'pages.extra',
  726. {
  727. authorName: 'author.name',
  728. authorEmail: 'author.email',
  729. creatorName: 'creator.name',
  730. creatorEmail: 'creator.email'
  731. }
  732. ])
  733. .joinRelated('author')
  734. .joinRelated('creator')
  735. .withGraphJoined('tags')
  736. .modifyGraph('tags', builder => {
  737. builder.select('tag', 'title')
  738. })
  739. .where(queryModeID ? {
  740. 'pages.id': opts
  741. } : {
  742. 'pages.path': opts.path,
  743. 'pages.localeCode': opts.locale
  744. })
  745. // .andWhere(builder => {
  746. // if (queryModeID) return
  747. // builder.where({
  748. // 'pages.isPublished': true
  749. // }).orWhere({
  750. // 'pages.isPublished': false,
  751. // 'pages.authorId': opts.userId
  752. // })
  753. // })
  754. // .andWhere(builder => {
  755. // if (queryModeID) return
  756. // if (opts.isPrivate) {
  757. // builder.where({ 'pages.isPrivate': true, 'pages.privateNS': opts.privateNS })
  758. // } else {
  759. // builder.where({ 'pages.isPrivate': false })
  760. // }
  761. // })
  762. .first()
  763. } catch (err) {
  764. WIKI.logger.warn(err)
  765. throw err
  766. }
  767. }
  768. /**
  769. * Save a Page Model Instance to Cache
  770. *
  771. * @param {Object} page Page Model Instance
  772. * @returns {Promise} Promise with no value
  773. */
  774. static async savePageToCache(page) {
  775. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
  776. await fs.outputFile(cachePath, WIKI.models.pages.cacheSchema.encode({
  777. id: page.id,
  778. authorId: page.authorId,
  779. authorName: page.authorName,
  780. createdAt: page.createdAt,
  781. creatorId: page.creatorId,
  782. creatorName: page.creatorName,
  783. description: page.description,
  784. extra: {
  785. css: _.get(page, 'extra.css', ''),
  786. js: _.get(page, 'extra.js', '')
  787. },
  788. isPrivate: page.isPrivate === 1 || page.isPrivate === true,
  789. isPublished: page.isPublished === 1 || page.isPublished === true,
  790. publishEndDate: page.publishEndDate,
  791. publishStartDate: page.publishStartDate,
  792. render: page.render,
  793. tags: page.tags.map(t => _.pick(t, ['tag', 'title'])),
  794. title: page.title,
  795. toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
  796. updatedAt: page.updatedAt
  797. }))
  798. }
  799. /**
  800. * Fetch an Existing Page from Cache
  801. *
  802. * @param {Object} opts Page Properties
  803. * @returns {Promise} Promise of the Page Model Instance
  804. */
  805. static async getPageFromCache(opts) {
  806. const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' })
  807. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
  808. try {
  809. const pageBuffer = await fs.readFile(cachePath)
  810. let page = WIKI.models.pages.cacheSchema.decode(pageBuffer)
  811. return {
  812. ...page,
  813. path: opts.path,
  814. localeCode: opts.locale,
  815. isPrivate: opts.isPrivate
  816. }
  817. } catch (err) {
  818. if (err.code === 'ENOENT') {
  819. return false
  820. }
  821. WIKI.logger.error(err)
  822. throw err
  823. }
  824. }
  825. /**
  826. * Delete an Existing Page from Cache
  827. *
  828. * @param {String} page Page Unique Hash
  829. * @returns {Promise} Promise with no value
  830. */
  831. static async deletePageFromCache(hash) {
  832. return fs.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
  833. }
  834. /**
  835. * Flush the contents of the Cache
  836. */
  837. static async flushCache() {
  838. return fs.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
  839. }
  840. /**
  841. * Migrate all pages from a source locale to the target locale
  842. *
  843. * @param {Object} opts Migration properties
  844. * @param {string} opts.sourceLocale Source Locale Code
  845. * @param {string} opts.targetLocale Target Locale Code
  846. * @returns {Promise} Promise with no value
  847. */
  848. static async migrateToLocale({ sourceLocale, targetLocale }) {
  849. return WIKI.models.pages.query()
  850. .patch({
  851. localeCode: targetLocale
  852. })
  853. .where({
  854. localeCode: sourceLocale
  855. })
  856. .whereNotExists(function() {
  857. this.select('id').from('pages AS pagesm').where('pagesm.localeCode', targetLocale).andWhereRaw('pagesm.path = pages.path')
  858. })
  859. }
  860. /**
  861. * Clean raw HTML from content for use in search engines
  862. *
  863. * @param {string} rawHTML Raw HTML
  864. * @returns {string} Cleaned Content Text
  865. */
  866. static cleanHTML(rawHTML = '') {
  867. let data = striptags(rawHTML || '', [], ' ')
  868. .replace(emojiRegex(), '')
  869. // .replace(htmlEntitiesRegex, '')
  870. return he.decode(data)
  871. .replace(punctuationRegex, ' ')
  872. .replace(/(\r\n|\n|\r)/gm, ' ')
  873. .replace(/\s\s+/g, ' ')
  874. .split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
  875. }
  876. /**
  877. * Subscribe to HA propagation events
  878. */
  879. static subscribeToEvents() {
  880. WIKI.events.inbound.on('deletePageFromCache', hash => {
  881. WIKI.models.pages.deletePageFromCache(hash)
  882. })
  883. WIKI.events.inbound.on('flushCache', () => {
  884. WIKI.models.pages.flushCache()
  885. })
  886. }
  887. }