pages.js 28 KB

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