page.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. const mongoose = require('mongoose');
  2. const escapeStringRegexp = require('escape-string-regexp');
  3. const logger = require('@alias/logger')('growi:models:page');
  4. const debug = require('debug')('growi:models:page');
  5. const { Writable } = require('stream');
  6. const { createBatchStream } = require('@server/util/batch-stream');
  7. const { isTrashPage } = require('@commons/util/path-utils');
  8. const { serializePageSecurely } = require('../models/serializers/page-serializer');
  9. const STATUS_PUBLISHED = 'published';
  10. const BULK_REINDEX_SIZE = 100;
  11. class PageService {
  12. constructor(crowi) {
  13. this.crowi = crowi;
  14. }
  15. async deleteCompletelyOperation(pageIds, pagePaths) {
  16. // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
  17. const Bookmark = this.crowi.model('Bookmark');
  18. const Comment = this.crowi.model('Comment');
  19. const Page = this.crowi.model('Page');
  20. const PageTagRelation = this.crowi.model('PageTagRelation');
  21. const ShareLink = this.crowi.model('ShareLink');
  22. const Revision = this.crowi.model('Revision');
  23. const Attachment = this.crowi.model('Attachment');
  24. const { attachmentService } = this.crowi;
  25. const attachments = await Attachment.find({ page: { $in: pageIds } });
  26. return Promise.all([
  27. Bookmark.find({ page: { $in: pageIds } }).remove({}),
  28. Comment.find({ page: { $in: pageIds } }).remove({}),
  29. PageTagRelation.find({ relatedPage: { $in: pageIds } }).remove({}),
  30. ShareLink.find({ relatedPage: { $in: pageIds } }).remove({}),
  31. Revision.find({ path: { $in: pagePaths } }).remove({}),
  32. Page.find({ _id: { $in: pageIds } }).remove({}),
  33. Page.find({ path: { $in: pagePaths } }).remove({}),
  34. attachmentService.removeAllAttachments(attachments),
  35. ]);
  36. }
  37. async duplicate(page, newPagePath, user, isRecursively) {
  38. const Page = this.crowi.model('Page');
  39. const PageTagRelation = mongoose.model('PageTagRelation');
  40. // populate
  41. await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
  42. // create option
  43. const options = { page };
  44. options.grant = page.grant;
  45. options.grantUserGroupId = page.grantedGroup;
  46. options.grantedUsers = page.grantedUsers;
  47. const createdPage = await Page.create(
  48. newPagePath, page.revision.body, user, options,
  49. );
  50. if (isRecursively) {
  51. this.duplicateDescendantsWithStream(page, newPagePath, user);
  52. }
  53. // take over tags
  54. const originTags = await page.findRelatedTagsById();
  55. let savedTags = [];
  56. if (originTags != null) {
  57. await PageTagRelation.updatePageTags(createdPage.id, originTags);
  58. savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
  59. }
  60. const result = serializePageSecurely(createdPage);
  61. result.tags = savedTags;
  62. return result;
  63. }
  64. /**
  65. * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
  66. * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
  67. */
  68. async duplicateTags(pageIdMapping) {
  69. const PageTagRelation = mongoose.model('PageTagRelation');
  70. // convert pageId from string to ObjectId
  71. const pageIds = Object.keys(pageIdMapping);
  72. const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
  73. const pagesAssociatedWithTag = await PageTagRelation.aggregate([
  74. {
  75. $match: stage,
  76. },
  77. {
  78. $group: {
  79. _id: '$relatedTag',
  80. relatedPages: { $push: '$relatedPage' },
  81. },
  82. },
  83. ]);
  84. const newPageTagRelation = [];
  85. pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
  86. // relatedPages
  87. relatedPages.forEach((pageId) => {
  88. newPageTagRelation.push({
  89. relatedPage: pageIdMapping[pageId], // newPageId
  90. relatedTag: _id,
  91. });
  92. });
  93. });
  94. return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
  95. }
  96. async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, pathRevisionMapping) {
  97. const Page = this.crowi.model('Page');
  98. const Revision = this.crowi.model('Revision');
  99. // key: oldPageId, value: newPageId
  100. const pageIdMapping = {};
  101. const newPages = [];
  102. const newRevisions = [];
  103. pages.forEach((page) => {
  104. const newPageId = new mongoose.Types.ObjectId();
  105. const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
  106. const revisionId = new mongoose.Types.ObjectId();
  107. pageIdMapping[page._id] = newPageId;
  108. newPages.push({
  109. _id: newPageId,
  110. path: newPagePath,
  111. creator: user._id,
  112. grant: page.grant,
  113. grantedGroup: page.grantedGroup,
  114. grantedUsers: page.grantedUsers,
  115. lastUpdateUser: user._id,
  116. redirectTo: null,
  117. revision: revisionId,
  118. });
  119. newRevisions.push({
  120. _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
  121. });
  122. });
  123. await Page.insertMany(newPages, { ordered: false });
  124. await Revision.insertMany(newRevisions, { ordered: false });
  125. await this.duplicateTags(pageIdMapping);
  126. }
  127. async duplicateDescendantsWithStream(page, newPagePath, user) {
  128. const Page = this.crowi.model('Page');
  129. const Revision = this.crowi.model('Revision');
  130. const newPagePathPrefix = newPagePath;
  131. const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
  132. const revisions = await Revision.find({ path: pathRegExp });
  133. const { PageQueryBuilder } = Page;
  134. const readStream = new PageQueryBuilder(Page.find())
  135. .addConditionToExcludeRedirect()
  136. .addConditionToListOnlyDescendants(page.path)
  137. .addConditionToFilteringByViewer(user)
  138. .query
  139. .lean()
  140. .cursor();
  141. // Mapping to set to the body of the new revision
  142. const pathRevisionMapping = {};
  143. revisions.forEach((revision) => {
  144. pathRevisionMapping[revision.path] = revision;
  145. });
  146. const duplicateDescendants = this.duplicateDescendants.bind(this);
  147. let count = 0;
  148. const writeStream = new Writable({
  149. objectMode: true,
  150. async write(batch, encoding, callback) {
  151. try {
  152. count += batch.length;
  153. await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, pathRevisionMapping);
  154. logger.debug(`Adding pages progressing: (count=${count})`);
  155. }
  156. catch (err) {
  157. logger.error('addAllPages error on add anyway: ', err);
  158. }
  159. callback();
  160. },
  161. final(callback) {
  162. logger.debug(`Adding pages has completed: (totalCount=${count})`);
  163. callback();
  164. },
  165. });
  166. readStream
  167. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  168. .pipe(writeStream);
  169. }
  170. async deletePage(pageData, user, options = {}) {
  171. const Page = this.crowi.model('Page');
  172. const newPath = Page.getDeletedPageName(pageData.path);
  173. const isTrashed = isTrashPage(pageData.path);
  174. if (isTrashed) {
  175. throw new Error('This method does NOT support deleting trashed pages.');
  176. }
  177. const socketClientId = options.socketClientId || null;
  178. if (Page.isDeletableName(pageData.path)) {
  179. pageData.status = Page.STATUS_DELETED;
  180. const updatedPageData = await Page.rename(pageData, newPath, user, { socketClientId, createRedirectPage: true });
  181. return updatedPageData;
  182. }
  183. return Promise.reject(new Error('Page is not deletable.'));
  184. }
  185. async deletePageRecursively(targetPage, user, options = {}) {
  186. const Page = this.crowi.model('Page');
  187. const isTrashed = isTrashPage(targetPage.path);
  188. const newPath = Page.getDeletedPageName(targetPage.path);
  189. if (isTrashed) {
  190. throw new Error('This method does NOT supports deleting trashed pages.');
  191. }
  192. if (!Page.isDeletableName(targetPage.path)) {
  193. throw new Error('Page is not deletable');
  194. }
  195. const socketClientId = options.socketClientId || null;
  196. targetPage.status = Page.STATUS_DELETED;
  197. return Page.renameRecursively(targetPage, newPath, user, { socketClientId, createRedirectPage: true });
  198. }
  199. // delete multiple pages
  200. async deleteMultipleCompletely(pages, user, options = {}) {
  201. this.validateCrowi();
  202. let pageEvent;
  203. // init event
  204. if (this.crowi != null) {
  205. pageEvent = this.crowi.event('page');
  206. pageEvent.on('create', pageEvent.onCreate);
  207. pageEvent.on('update', pageEvent.onUpdate);
  208. }
  209. const ids = pages.map(page => (page._id));
  210. const paths = pages.map(page => (page.path));
  211. const socketClientId = options.socketClientId || null;
  212. logger.debug('Deleting completely', paths);
  213. await this.deleteCompletelyOperation(ids, paths);
  214. if (socketClientId != null) {
  215. pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
  216. }
  217. return;
  218. }
  219. async deleteCompletely(page, user, options = {}, isRecursively = false) {
  220. this.validateCrowi();
  221. let pageEvent;
  222. // init event
  223. if (this.crowi != null) {
  224. pageEvent = this.crowi.event('page');
  225. pageEvent.on('create', pageEvent.onCreate);
  226. pageEvent.on('update', pageEvent.onUpdate);
  227. }
  228. const ids = [page._id];
  229. const paths = [page.path];
  230. const socketClientId = options.socketClientId || null;
  231. logger.debug('Deleting completely', paths);
  232. await this.deleteCompletelyOperation(ids, paths);
  233. if (isRecursively) {
  234. this.deleteDescendantsWithStream(page, user, options);
  235. }
  236. if (socketClientId != null) {
  237. pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
  238. }
  239. return;
  240. }
  241. /**
  242. * Create delete stream
  243. */
  244. async deleteDescendantsWithStream(targetPage, user, options = {}) {
  245. const Page = this.crowi.model('Page');
  246. const { PageQueryBuilder } = Page;
  247. const readStream = new PageQueryBuilder(Page.find())
  248. .addConditionToExcludeRedirect()
  249. .addConditionToListOnlyDescendants(targetPage.path)
  250. .addConditionToFilteringByViewer(user)
  251. .query
  252. .lean()
  253. .cursor();
  254. const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
  255. let count = 0;
  256. const writeStream = new Writable({
  257. objectMode: true,
  258. async write(batch, encoding, callback) {
  259. try {
  260. count += batch.length;
  261. await deleteMultipleCompletely(batch, user, options);
  262. logger.debug(`Adding pages progressing: (count=${count})`);
  263. }
  264. catch (err) {
  265. logger.error('addAllPages error on add anyway: ', err);
  266. }
  267. callback();
  268. },
  269. final(callback) {
  270. logger.debug(`Adding pages has completed: (totalCount=${count})`);
  271. callback();
  272. },
  273. });
  274. readStream
  275. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  276. .pipe(writeStream);
  277. }
  278. async revertDeletedPages(pages, user) {
  279. const Page = this.crowi.model('Page');
  280. const pageCollection = mongoose.connection.collection('pages');
  281. const revisionCollection = mongoose.connection.collection('revisions');
  282. const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
  283. const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
  284. const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
  285. // e.g. key: '/test'
  286. const pathToPageMapping = {};
  287. const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
  288. const toPages = await Page.find({ path: { $in: toPaths } });
  289. toPages.forEach((toPage) => {
  290. pathToPageMapping[toPage.path] = toPage;
  291. });
  292. pages.forEach((page) => {
  293. // e.g. page.path = /trash/test, toPath = /test
  294. const toPath = Page.getRevertDeletedPageName(page.path);
  295. if (pathToPageMapping[toPath] != null) {
  296. // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
  297. // So, it's ok to delete the page
  298. // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
  299. if (pathToPageMapping[toPath].redirectTo === page.path) {
  300. removePageBulkOp.find({ path: toPath }).remove();
  301. }
  302. }
  303. revertPageBulkOp.find({ _id: page._id }).update({ $set: { path: toPath, status: STATUS_PUBLISHED, lastUpdateUser: user._id } });
  304. revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
  305. });
  306. try {
  307. await removePageBulkOp.execute();
  308. await revertPageBulkOp.execute();
  309. await revertRevisionBulkOp.execute();
  310. }
  311. catch (err) {
  312. if (err.code !== 11000) {
  313. throw new Error('Failed to revert pages: ', err);
  314. }
  315. }
  316. }
  317. async revertDeletedPage(page, user, options = {}, isRecursively = false) {
  318. const Page = this.crowi.model('Page');
  319. const newPath = Page.getRevertDeletedPageName(page.path);
  320. const originPage = await Page.findByPath(newPath);
  321. if (originPage != null) {
  322. // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
  323. // So, it's ok to delete the page
  324. // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
  325. if (originPage.redirectTo !== page.path) {
  326. throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
  327. }
  328. await this.deleteCompletely(originPage, options);
  329. }
  330. if (isRecursively) {
  331. this.revertDeletedDescendantsWithStream(page, user, options);
  332. }
  333. page.status = STATUS_PUBLISHED;
  334. page.lastUpdateUser = user;
  335. debug('Revert deleted the page', page, newPath);
  336. const updatedPage = await Page.rename(page, newPath, user, {});
  337. return updatedPage;
  338. }
  339. /**
  340. * Create revert stream
  341. */
  342. async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
  343. const Page = this.crowi.model('Page');
  344. const { PageQueryBuilder } = Page;
  345. const readStream = new PageQueryBuilder(Page.find())
  346. .addConditionToExcludeRedirect()
  347. .addConditionToListOnlyDescendants(targetPage.path)
  348. .addConditionToFilteringByViewer(user)
  349. .query
  350. .lean()
  351. .cursor();
  352. const revertDeletedPages = this.revertDeletedPages.bind(this);
  353. let count = 0;
  354. const writeStream = new Writable({
  355. objectMode: true,
  356. async write(batch, encoding, callback) {
  357. try {
  358. count += batch.length;
  359. revertDeletedPages(batch, user);
  360. logger.debug(`Reverting pages progressing: (count=${count})`);
  361. }
  362. catch (err) {
  363. logger.error('revertPages error on add anyway: ', err);
  364. }
  365. callback();
  366. },
  367. final(callback) {
  368. logger.debug(`Reverting pages has completed: (totalCount=${count})`);
  369. callback();
  370. },
  371. });
  372. readStream
  373. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  374. .pipe(writeStream);
  375. }
  376. async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId) {
  377. const Page = this.crowi.model('Page');
  378. const pages = await Page.find({ grantedGroup: deletedGroup });
  379. switch (action) {
  380. case 'public':
  381. await Promise.all(pages.map((page) => {
  382. return Page.publicizePage(page);
  383. }));
  384. break;
  385. case 'delete':
  386. return this.deleteMultiplePagesCompletely(pages);
  387. case 'transfer':
  388. await Promise.all(pages.map((page) => {
  389. return Page.transferPageToGroup(page, transferToUserGroupId);
  390. }));
  391. break;
  392. default:
  393. throw new Error('Unknown action for private pages');
  394. }
  395. }
  396. validateCrowi() {
  397. if (this.crowi == null) {
  398. throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
  399. }
  400. }
  401. }
  402. module.exports = PageService;