page.js 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
  1. import { pagePathUtils } from '@growi/core';
  2. import loggerFactory from '~/utils/logger';
  3. const mongoose = require('mongoose');
  4. const escapeStringRegexp = require('escape-string-regexp');
  5. const streamToPromise = require('stream-to-promise');
  6. const pathlib = require('path');
  7. const logger = loggerFactory('growi:services:page');
  8. const debug = require('debug')('growi:services:page');
  9. const { Writable } = require('stream');
  10. const { createBatchStream } = require('~/server/util/batch-stream');
  11. const { isCreatablePage, isDeletablePage, isTrashPage } = pagePathUtils;
  12. const { serializePageSecurely } = require('../models/serializers/page-serializer');
  13. const BULK_REINDEX_SIZE = 100;
  14. class PageService {
  15. constructor(crowi) {
  16. this.crowi = crowi;
  17. this.pageEvent = crowi.event('page');
  18. // init
  19. this.pageEvent.on('create', this.pageEvent.onCreate);
  20. this.pageEvent.on('update', this.pageEvent.onUpdate);
  21. this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
  22. }
  23. async findPageAndMetaDataByViewer({ pageId, path, user }) {
  24. const Page = this.crowi.model('Page');
  25. let page;
  26. if (pageId != null) { // prioritized
  27. page = await Page.findByIdAndViewer(pageId, user);
  28. }
  29. else {
  30. page = await Page.findByPathAndViewer(path, user);
  31. }
  32. const result = {};
  33. if (page == null) {
  34. const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
  35. result.isForbidden = isExist;
  36. result.isNotFound = !isExist;
  37. result.isCreatable = isCreatablePage(path);
  38. result.isDeletable = false;
  39. result.canDeleteCompletely = false;
  40. result.page = page;
  41. return result;
  42. }
  43. result.page = page;
  44. result.isForbidden = false;
  45. result.isNotFound = false;
  46. result.isCreatable = false;
  47. result.isDeletable = isDeletablePage(path);
  48. result.isDeleted = page.isDeleted();
  49. result.canDeleteCompletely = user != null && user.canDeleteCompletely(page.creator);
  50. return result;
  51. }
  52. /**
  53. * go back by using redirectTo and return the paths
  54. * ex: when
  55. * '/page1' redirects to '/page2' and
  56. * '/page2' redirects to '/page3'
  57. * and given '/page3',
  58. * '/page1' and '/page2' will be return
  59. *
  60. * @param {string} redirectTo
  61. * @param {object} redirectToPagePathMapping
  62. * @param {array} pagePaths
  63. */
  64. prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
  65. const pagePath = redirectToPagePathMapping[redirectTo];
  66. if (pagePath == null) {
  67. return pagePaths;
  68. }
  69. pagePaths.push(pagePath);
  70. return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
  71. }
  72. /**
  73. * Generate read stream to operate descendants of the specified page path
  74. * @param {string} targetPagePath
  75. * @param {User} viewer
  76. */
  77. async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
  78. const Page = this.crowi.model('Page');
  79. const { PageQueryBuilder } = Page;
  80. const builder = new PageQueryBuilder(Page.find())
  81. .addConditionToExcludeRedirect()
  82. .addConditionToListOnlyDescendants(targetPagePath);
  83. await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
  84. return builder
  85. .query
  86. .lean()
  87. .cursor({ batchSize: BULK_REINDEX_SIZE });
  88. }
  89. async renamePage(page, newPagePath, user, options, isRecursively = false) {
  90. const Page = this.crowi.model('Page');
  91. const Revision = this.crowi.model('Revision');
  92. const path = page.path;
  93. const createRedirectPage = options.createRedirectPage || false;
  94. const updateMetadata = options.updateMetadata || false;
  95. // sanitize path
  96. newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
  97. // create descendants first
  98. if (isRecursively) {
  99. await this.renameDescendantsWithStream(page, newPagePath, user, options);
  100. }
  101. const update = {};
  102. // update Page
  103. update.path = newPagePath;
  104. if (updateMetadata) {
  105. update.lastUpdateUser = user;
  106. update.updatedAt = Date.now();
  107. }
  108. const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
  109. // update Rivisions
  110. await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
  111. if (createRedirectPage) {
  112. const body = `redirect ${newPagePath}`;
  113. await Page.create(path, body, user, { redirectTo: newPagePath });
  114. }
  115. this.pageEvent.emit('delete', page, user);
  116. this.pageEvent.emit('create', renamedPage, user);
  117. return renamedPage;
  118. }
  119. async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
  120. const Page = this.crowi.model('Page');
  121. const pageCollection = mongoose.connection.collection('pages');
  122. const revisionCollection = mongoose.connection.collection('revisions');
  123. const { updateMetadata, createRedirectPage } = options;
  124. const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
  125. const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
  126. const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
  127. const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
  128. pages.forEach((page) => {
  129. const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
  130. const revisionId = new mongoose.Types.ObjectId();
  131. if (updateMetadata) {
  132. unorderedBulkOp
  133. .find({ _id: page._id })
  134. .update({ $set: { path: newPagePath, lastUpdateUser: user._id, updatedAt: new Date() } });
  135. }
  136. else {
  137. unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
  138. }
  139. if (createRedirectPage) {
  140. createRediectPageBulkOp.insert({
  141. path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
  142. });
  143. createRediectRevisionBulkOp.insert({
  144. _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
  145. });
  146. }
  147. revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
  148. });
  149. try {
  150. await unorderedBulkOp.execute();
  151. await revisionUnorderedBulkOp.execute();
  152. // Execute after unorderedBulkOp to prevent duplication
  153. if (createRedirectPage) {
  154. await createRediectPageBulkOp.execute();
  155. await createRediectRevisionBulkOp.execute();
  156. }
  157. }
  158. catch (err) {
  159. if (err.code !== 11000) {
  160. throw new Error('Failed to rename pages: ', err);
  161. }
  162. }
  163. this.pageEvent.emit('updateMany', pages, user);
  164. }
  165. /**
  166. * Create rename stream
  167. */
  168. async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
  169. const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
  170. const newPagePathPrefix = newPagePath;
  171. const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
  172. const renameDescendants = this.renameDescendants.bind(this);
  173. const pageEvent = this.pageEvent;
  174. let count = 0;
  175. const writeStream = new Writable({
  176. objectMode: true,
  177. async write(batch, encoding, callback) {
  178. try {
  179. count += batch.length;
  180. await renameDescendants(batch, user, options, pathRegExp, newPagePathPrefix);
  181. logger.debug(`Reverting pages progressing: (count=${count})`);
  182. }
  183. catch (err) {
  184. logger.error('revertPages error on add anyway: ', err);
  185. }
  186. callback();
  187. },
  188. final(callback) {
  189. logger.debug(`Reverting pages has completed: (totalCount=${count})`);
  190. // update path
  191. targetPage.path = newPagePath;
  192. pageEvent.emit('syncDescendants', targetPage, user);
  193. callback();
  194. },
  195. });
  196. readStream
  197. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  198. .pipe(writeStream);
  199. await streamToPromise(readStream);
  200. }
  201. async deleteCompletelyOperation(pageIds, pagePaths) {
  202. // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
  203. const Bookmark = this.crowi.model('Bookmark');
  204. const Comment = this.crowi.model('Comment');
  205. const Page = this.crowi.model('Page');
  206. const PageTagRelation = this.crowi.model('PageTagRelation');
  207. const ShareLink = this.crowi.model('ShareLink');
  208. const Revision = this.crowi.model('Revision');
  209. const Attachment = this.crowi.model('Attachment');
  210. const { attachmentService } = this.crowi;
  211. const attachments = await Attachment.find({ page: { $in: pageIds } });
  212. const pages = await Page.find({ redirectTo: { $ne: null } });
  213. const redirectToPagePathMapping = {};
  214. pages.forEach((page) => {
  215. redirectToPagePathMapping[page.redirectTo] = page.path;
  216. });
  217. const redirectedFromPagePaths = [];
  218. pagePaths.forEach((pagePath) => {
  219. redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
  220. });
  221. return Promise.all([
  222. Bookmark.deleteMany({ page: { $in: pageIds } }),
  223. Comment.deleteMany({ page: { $in: pageIds } }),
  224. PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
  225. ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
  226. Revision.deleteMany({ path: { $in: pagePaths } }),
  227. Page.deleteMany({ $or: [{ path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } }] }),
  228. attachmentService.removeAllAttachments(attachments),
  229. ]);
  230. }
  231. async duplicate(page, newPagePath, user, isRecursively) {
  232. const Page = this.crowi.model('Page');
  233. const PageTagRelation = mongoose.model('PageTagRelation');
  234. // populate
  235. await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
  236. // create option
  237. const options = { page };
  238. options.grant = page.grant;
  239. options.grantUserGroupId = page.grantedGroup;
  240. options.grantedUsers = page.grantedUsers;
  241. newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
  242. const createdPage = await Page.create(
  243. newPagePath, page.revision.body, user, options,
  244. );
  245. if (isRecursively) {
  246. this.duplicateDescendantsWithStream(page, newPagePath, user);
  247. }
  248. // take over tags
  249. const originTags = await page.findRelatedTagsById();
  250. let savedTags = [];
  251. if (originTags != null) {
  252. await PageTagRelation.updatePageTags(createdPage.id, originTags);
  253. savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
  254. }
  255. const result = serializePageSecurely(createdPage);
  256. result.tags = savedTags;
  257. return result;
  258. }
  259. /**
  260. * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
  261. * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
  262. */
  263. async duplicateTags(pageIdMapping) {
  264. const PageTagRelation = mongoose.model('PageTagRelation');
  265. // convert pageId from string to ObjectId
  266. const pageIds = Object.keys(pageIdMapping);
  267. const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
  268. const pagesAssociatedWithTag = await PageTagRelation.aggregate([
  269. {
  270. $match: stage,
  271. },
  272. {
  273. $group: {
  274. _id: '$relatedTag',
  275. relatedPages: { $push: '$relatedPage' },
  276. },
  277. },
  278. ]);
  279. const newPageTagRelation = [];
  280. pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
  281. // relatedPages
  282. relatedPages.forEach((pageId) => {
  283. newPageTagRelation.push({
  284. relatedPage: pageIdMapping[pageId], // newPageId
  285. relatedTag: _id,
  286. });
  287. });
  288. });
  289. return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
  290. }
  291. async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
  292. const Page = this.crowi.model('Page');
  293. const Revision = this.crowi.model('Revision');
  294. const paths = pages.map(page => (page.path));
  295. const revisions = await Revision.find({ path: { $in: paths } });
  296. // Mapping to set to the body of the new revision
  297. const pathRevisionMapping = {};
  298. revisions.forEach((revision) => {
  299. pathRevisionMapping[revision.path] = revision;
  300. });
  301. // key: oldPageId, value: newPageId
  302. const pageIdMapping = {};
  303. const newPages = [];
  304. const newRevisions = [];
  305. pages.forEach((page) => {
  306. const newPageId = new mongoose.Types.ObjectId();
  307. const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
  308. const revisionId = new mongoose.Types.ObjectId();
  309. pageIdMapping[page._id] = newPageId;
  310. newPages.push({
  311. _id: newPageId,
  312. path: newPagePath,
  313. creator: user._id,
  314. grant: page.grant,
  315. grantedGroup: page.grantedGroup,
  316. grantedUsers: page.grantedUsers,
  317. lastUpdateUser: user._id,
  318. redirectTo: null,
  319. revision: revisionId,
  320. });
  321. newRevisions.push({
  322. _id: revisionId, path: newPagePath, body: pathRevisionMapping[page.path].body, author: user._id, format: 'markdown',
  323. });
  324. });
  325. await Page.insertMany(newPages, { ordered: false });
  326. await Revision.insertMany(newRevisions, { ordered: false });
  327. await this.duplicateTags(pageIdMapping);
  328. }
  329. async duplicateDescendantsWithStream(page, newPagePath, user) {
  330. const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
  331. const newPagePathPrefix = newPagePath;
  332. const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
  333. const duplicateDescendants = this.duplicateDescendants.bind(this);
  334. const pageEvent = this.pageEvent;
  335. let count = 0;
  336. const writeStream = new Writable({
  337. objectMode: true,
  338. async write(batch, encoding, callback) {
  339. try {
  340. count += batch.length;
  341. await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
  342. logger.debug(`Adding pages progressing: (count=${count})`);
  343. }
  344. catch (err) {
  345. logger.error('addAllPages error on add anyway: ', err);
  346. }
  347. callback();
  348. },
  349. final(callback) {
  350. logger.debug(`Adding pages has completed: (totalCount=${count})`);
  351. // update path
  352. page.path = newPagePath;
  353. pageEvent.emit('syncDescendants', page, user);
  354. callback();
  355. },
  356. });
  357. readStream
  358. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  359. .pipe(writeStream);
  360. }
  361. async deletePage(page, user, options = {}, isRecursively = false) {
  362. const Page = this.crowi.model('Page');
  363. const Revision = this.crowi.model('Revision');
  364. const newPath = Page.getDeletedPageName(page.path);
  365. const isTrashed = isTrashPage(page.path);
  366. if (isTrashed) {
  367. throw new Error('This method does NOT support deleting trashed pages.');
  368. }
  369. if (!Page.isDeletableName(page.path)) {
  370. throw new Error('Page is not deletable.');
  371. }
  372. if (isRecursively) {
  373. this.deleteDescendantsWithStream(page, user, options);
  374. }
  375. // update Rivisions
  376. await Revision.updateRevisionListByPath(page.path, { path: newPath }, {});
  377. const deletedPage = await Page.findByIdAndUpdate(page._id, {
  378. $set: {
  379. path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
  380. },
  381. }, { new: true });
  382. const body = `redirect ${newPath}`;
  383. await Page.create(page.path, body, user, { redirectTo: newPath });
  384. this.pageEvent.emit('delete', page, user);
  385. this.pageEvent.emit('create', deletedPage, user);
  386. return deletedPage;
  387. }
  388. async deleteDescendants(pages, user) {
  389. const Page = this.crowi.model('Page');
  390. const pageCollection = mongoose.connection.collection('pages');
  391. const revisionCollection = mongoose.connection.collection('revisions');
  392. const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
  393. const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
  394. const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
  395. const newPagesForRedirect = [];
  396. pages.forEach((page) => {
  397. const newPath = Page.getDeletedPageName(page.path);
  398. const revisionId = new mongoose.Types.ObjectId();
  399. const body = `redirect ${newPath}`;
  400. deletePageBulkOp.find({ _id: page._id }).update({
  401. $set: {
  402. path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
  403. },
  404. });
  405. updateRevisionListOp.find({ path: page.path }).update({ $set: { path: newPath } });
  406. createRediectRevisionBulkOp.insert({
  407. _id: revisionId, path: page.path, body, author: user._id, format: 'markdown',
  408. });
  409. newPagesForRedirect.push({
  410. path: page.path,
  411. creator: user._id,
  412. grant: page.grant,
  413. grantedGroup: page.grantedGroup,
  414. grantedUsers: page.grantedUsers,
  415. lastUpdateUser: user._id,
  416. redirectTo: newPath,
  417. revision: revisionId,
  418. });
  419. });
  420. try {
  421. await deletePageBulkOp.execute();
  422. await updateRevisionListOp.execute();
  423. await createRediectRevisionBulkOp.execute();
  424. await Page.insertMany(newPagesForRedirect, { ordered: false });
  425. }
  426. catch (err) {
  427. if (err.code !== 11000) {
  428. throw new Error('Failed to revert pages: ', err);
  429. }
  430. }
  431. }
  432. /**
  433. * Create delete stream
  434. */
  435. async deleteDescendantsWithStream(targetPage, user, options = {}) {
  436. const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
  437. const deleteDescendants = this.deleteDescendants.bind(this);
  438. let count = 0;
  439. const writeStream = new Writable({
  440. objectMode: true,
  441. async write(batch, encoding, callback) {
  442. try {
  443. count += batch.length;
  444. deleteDescendants(batch, user);
  445. logger.debug(`Reverting pages progressing: (count=${count})`);
  446. }
  447. catch (err) {
  448. logger.error('revertPages error on add anyway: ', err);
  449. }
  450. callback();
  451. },
  452. final(callback) {
  453. logger.debug(`Reverting pages has completed: (totalCount=${count})`);
  454. callback();
  455. },
  456. });
  457. readStream
  458. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  459. .pipe(writeStream);
  460. }
  461. // delete multiple pages
  462. async deleteMultipleCompletely(pages, user, options = {}) {
  463. const ids = pages.map(page => (page._id));
  464. const paths = pages.map(page => (page.path));
  465. logger.debug('Deleting completely', paths);
  466. await this.deleteCompletelyOperation(ids, paths);
  467. this.pageEvent.emit('deleteCompletely', pages, user); // update as renamed page
  468. return;
  469. }
  470. async deleteCompletely(page, user, options = {}, isRecursively = false) {
  471. const ids = [page._id];
  472. const paths = [page.path];
  473. logger.debug('Deleting completely', paths);
  474. await this.deleteCompletelyOperation(ids, paths);
  475. if (isRecursively) {
  476. this.deleteCompletelyDescendantsWithStream(page, user, options);
  477. }
  478. this.pageEvent.emit('delete', page, user); // update as renamed page
  479. return;
  480. }
  481. /**
  482. * Create delete completely stream
  483. */
  484. async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
  485. const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
  486. const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
  487. let count = 0;
  488. const writeStream = new Writable({
  489. objectMode: true,
  490. async write(batch, encoding, callback) {
  491. try {
  492. count += batch.length;
  493. await deleteMultipleCompletely(batch, user, options);
  494. logger.debug(`Adding pages progressing: (count=${count})`);
  495. }
  496. catch (err) {
  497. logger.error('addAllPages error on add anyway: ', err);
  498. }
  499. callback();
  500. },
  501. final(callback) {
  502. logger.debug(`Adding pages has completed: (totalCount=${count})`);
  503. callback();
  504. },
  505. });
  506. readStream
  507. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  508. .pipe(writeStream);
  509. }
  510. async revertDeletedDescendants(pages, user) {
  511. const Page = this.crowi.model('Page');
  512. const pageCollection = mongoose.connection.collection('pages');
  513. const revisionCollection = mongoose.connection.collection('revisions');
  514. const removePageBulkOp = pageCollection.initializeUnorderedBulkOp();
  515. const revertPageBulkOp = pageCollection.initializeUnorderedBulkOp();
  516. const revertRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
  517. // e.g. key: '/test'
  518. const pathToPageMapping = {};
  519. const toPaths = pages.map(page => Page.getRevertDeletedPageName(page.path));
  520. const toPages = await Page.find({ path: { $in: toPaths } });
  521. toPages.forEach((toPage) => {
  522. pathToPageMapping[toPage.path] = toPage;
  523. });
  524. pages.forEach((page) => {
  525. // e.g. page.path = /trash/test, toPath = /test
  526. const toPath = Page.getRevertDeletedPageName(page.path);
  527. if (pathToPageMapping[toPath] != null) {
  528. // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
  529. // So, it's ok to delete the page
  530. // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
  531. if (pathToPageMapping[toPath].redirectTo === page.path) {
  532. removePageBulkOp.find({ path: toPath }).delete();
  533. }
  534. }
  535. revertPageBulkOp.find({ _id: page._id }).update({
  536. $set: {
  537. path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
  538. },
  539. });
  540. revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
  541. });
  542. try {
  543. await removePageBulkOp.execute();
  544. await revertPageBulkOp.execute();
  545. await revertRevisionBulkOp.execute();
  546. }
  547. catch (err) {
  548. if (err.code !== 11000) {
  549. throw new Error('Failed to revert pages: ', err);
  550. }
  551. }
  552. }
  553. async revertDeletedPage(page, user, options = {}, isRecursively = false) {
  554. const Page = this.crowi.model('Page');
  555. const Revision = this.crowi.model('Revision');
  556. const newPath = Page.getRevertDeletedPageName(page.path);
  557. const originPage = await Page.findByPath(newPath);
  558. if (originPage != null) {
  559. // When the page is deleted, it will always be created with "redirectTo" in the path of the original page.
  560. // So, it's ok to delete the page
  561. // However, If a page exists that is not "redirectTo", something is wrong. (Data correction is needed).
  562. if (originPage.redirectTo !== page.path) {
  563. throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
  564. }
  565. await this.deleteCompletely(originPage, options);
  566. }
  567. if (isRecursively) {
  568. this.revertDeletedDescendantsWithStream(page, user, options);
  569. }
  570. page.status = Page.STATUS_PUBLISHED;
  571. page.lastUpdateUser = user;
  572. debug('Revert deleted the page', page, newPath);
  573. const updatedPage = await Page.findByIdAndUpdate(page._id, {
  574. $set: {
  575. path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
  576. },
  577. }, { new: true });
  578. await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
  579. return updatedPage;
  580. }
  581. /**
  582. * Create revert stream
  583. */
  584. async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
  585. const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
  586. const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
  587. let count = 0;
  588. const writeStream = new Writable({
  589. objectMode: true,
  590. async write(batch, encoding, callback) {
  591. try {
  592. count += batch.length;
  593. revertDeletedDescendants(batch, user);
  594. logger.debug(`Reverting pages progressing: (count=${count})`);
  595. }
  596. catch (err) {
  597. logger.error('revertPages error on add anyway: ', err);
  598. }
  599. callback();
  600. },
  601. final(callback) {
  602. logger.debug(`Reverting pages has completed: (totalCount=${count})`);
  603. callback();
  604. },
  605. });
  606. readStream
  607. .pipe(createBatchStream(BULK_REINDEX_SIZE))
  608. .pipe(writeStream);
  609. }
  610. async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user) {
  611. const Page = this.crowi.model('Page');
  612. const pages = await Page.find({ grantedGroup: deletedGroup });
  613. switch (action) {
  614. case 'public':
  615. await Promise.all(pages.map((page) => {
  616. return Page.publicizePage(page);
  617. }));
  618. break;
  619. case 'delete':
  620. return this.deleteMultipleCompletely(pages, user);
  621. case 'transfer':
  622. await Promise.all(pages.map((page) => {
  623. return Page.transferPageToGroup(page, transferToUserGroupId);
  624. }));
  625. break;
  626. default:
  627. throw new Error('Unknown action for private pages');
  628. }
  629. }
  630. validateCrowi() {
  631. if (this.crowi == null) {
  632. throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
  633. }
  634. }
  635. async v5MigrationByPageIds(pageIds) {
  636. const Page = mongoose.model('Page');
  637. if (pageIds == null || pageIds.length === 0) {
  638. return;
  639. }
  640. // generate regexps
  641. const regexps = await this._generateRegExpsByPageIds(pageIds);
  642. // migrate recursively
  643. try {
  644. await this._v5RecursiveMigration(null, regexps);
  645. }
  646. catch (err) {
  647. logger.error('V5 initial miration failed.', err);
  648. // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
  649. throw err;
  650. }
  651. }
  652. async v5InitialMigration(grant) {
  653. const socket = this.crowi.socketIoService.getAdminSocket();
  654. try {
  655. await this._v5RecursiveMigration(grant);
  656. }
  657. catch (err) {
  658. logger.error('V5 initial miration failed.', err);
  659. socket.emit('v5InitialMirationFailed', { error: err.message });
  660. throw err;
  661. }
  662. const Page = this.crowi.model('Page');
  663. const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
  664. const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
  665. const isPathIndexExists = pathIndexStatus != null;
  666. const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
  667. if (isUnique || !isPathIndexExists) {
  668. try {
  669. await this._v5NormalizeIndex(isPathIndexExists);
  670. }
  671. catch (err) {
  672. logger.error('V5 index normalization failed.', err);
  673. socket.emit('v5IndexNormalizationFailed', { error: err.message });
  674. throw err;
  675. }
  676. }
  677. await this._setIsV5CompatibleTrue();
  678. }
  679. /*
  680. * returns an array of js RegExp instance instead of RE2 instance for mongo filter
  681. */
  682. async _generateRegExpsByPageIds(pageIds) {
  683. const Page = mongoose.model('Page');
  684. let result;
  685. try {
  686. result = await Page.findListByPageIds(pageIds, null, false);
  687. }
  688. catch (err) {
  689. logger.error('Failed to find pages by ids', err);
  690. throw err;
  691. }
  692. const { pages } = result;
  693. const regexps = pages.map(page => new RegExp(`^${page.path}`));
  694. return regexps;
  695. }
  696. async _setIsV5CompatibleTrue() {
  697. try {
  698. await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
  699. 'app:isV5Compatible': true,
  700. });
  701. logger.info('Successfully migrated all public pages.');
  702. }
  703. catch (err) {
  704. logger.warn('Failed to update app:isV5Compatible to true.');
  705. throw err;
  706. }
  707. }
  708. // TODO: use websocket to show progress
  709. async _v5RecursiveMigration(grant, regexps) {
  710. const BATCH_SIZE = 100;
  711. const PAGES_LIMIT = 1000;
  712. const Page = this.crowi.model('Page');
  713. const { PageQueryBuilder } = Page;
  714. // generate filter
  715. let filter = {
  716. parent: null,
  717. path: { $ne: '/' },
  718. };
  719. if (grant != null) {
  720. filter = {
  721. ...filter,
  722. grant,
  723. };
  724. }
  725. if (regexps != null && regexps.length !== 0) {
  726. filter = {
  727. ...filter,
  728. path: {
  729. $in: regexps,
  730. },
  731. };
  732. }
  733. const total = await Page.countDocuments(filter);
  734. let baseAggregation = Page
  735. .aggregate([
  736. {
  737. $match: filter,
  738. },
  739. {
  740. $project: { // minimize data to fetch
  741. _id: 1,
  742. path: 1,
  743. },
  744. },
  745. ]);
  746. // limit pages to get
  747. if (total > PAGES_LIMIT) {
  748. baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
  749. }
  750. const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
  751. // use batch stream
  752. const batchStream = createBatchStream(BATCH_SIZE);
  753. let countPages = 0;
  754. // migrate all siblings for each page
  755. const migratePagesStream = new Writable({
  756. objectMode: true,
  757. async write(pages, encoding, callback) {
  758. // make list to create empty pages
  759. const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
  760. const parentPaths = Array.from(parentPathsSet);
  761. // fill parents with empty pages
  762. await Page.createEmptyPagesByPaths(parentPaths);
  763. // find parents again
  764. const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
  765. const parents = await builder
  766. .addConditionToListByPathsArray(parentPaths)
  767. .query
  768. .lean()
  769. .exec();
  770. // bulkWrite to update parent
  771. const updateManyOperations = parents.map((parent) => {
  772. const parentId = parent._id;
  773. // modify to adjust for RegExp
  774. const parentPath = parent.path === '/' ? '' : parent.path;
  775. return {
  776. updateMany: {
  777. filter: {
  778. // regexr.com/6889f
  779. // ex. /parent/any_child OR /any_level1
  780. path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
  781. },
  782. update: {
  783. parent: parentId,
  784. },
  785. },
  786. };
  787. });
  788. try {
  789. const res = await Page.bulkWrite(updateManyOperations);
  790. countPages += (res.items || []).length;
  791. logger.info(`Page migration processing: (count=${countPages}, errors=${res.errors}, took=${res.took}ms)`);
  792. }
  793. catch (err) {
  794. logger.error('Failed to update page.parent.', err);
  795. throw err;
  796. }
  797. callback();
  798. },
  799. final(callback) {
  800. callback();
  801. },
  802. });
  803. pagesStream
  804. .pipe(batchStream)
  805. .pipe(migratePagesStream);
  806. await streamToPromise(migratePagesStream);
  807. if (await Page.exists(filter)) {
  808. return this._v5RecursiveMigration(grant, regexps);
  809. }
  810. }
  811. async _v5NormalizeIndex(isPathIndexExists) {
  812. const collection = mongoose.connection.collection('pages');
  813. if (isPathIndexExists) {
  814. try {
  815. // drop pages.path_1 indexes
  816. await collection.dropIndex('path_1');
  817. logger.info('Succeeded to drop unique indexes from pages.path.');
  818. }
  819. catch (err) {
  820. logger.warn('Failed to drop unique indexes from pages.path.', err);
  821. throw err;
  822. }
  823. }
  824. try {
  825. // create indexes without
  826. await collection.createIndex({ path: 1 }, { unique: false });
  827. logger.info('Succeeded to create non-unique indexes on pages.path.');
  828. }
  829. catch (err) {
  830. logger.warn('Failed to create non-unique indexes on pages.path.', err);
  831. throw err;
  832. }
  833. }
  834. async v5MigratablePrivatePagesCount(user) {
  835. if (user == null) {
  836. throw Error('user is required');
  837. }
  838. const Page = this.crowi.model('Page');
  839. return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
  840. }
  841. }
  842. module.exports = PageService;