pages.js 27 KB

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