page.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import assert from 'assert';
  3. import nodePath from 'path';
  4. import {
  5. type IPage,
  6. GroupType, type HasObjectId,
  7. } from '@growi/core';
  8. import { isPopulated } from '@growi/core/dist/interfaces';
  9. import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
  10. import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
  11. import escapeStringRegexp from 'escape-string-regexp';
  12. import type { Model, Document, AnyObject } from 'mongoose';
  13. import mongoose, {
  14. Schema,
  15. } from 'mongoose';
  16. import mongoosePaginate from 'mongoose-paginate-v2';
  17. import uniqueValidator from 'mongoose-unique-validator';
  18. import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
  19. import type { IOptionsForCreate } from '~/interfaces/page';
  20. import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
  21. import loggerFactory from '../../utils/logger';
  22. import { getOrCreateModel } from '../util/mongoose-utils';
  23. import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
  24. import UserGroupRelation from './user-group-relation';
  25. const logger = loggerFactory('growi:models:page');
  26. /*
  27. * define schema
  28. */
  29. const GRANT_PUBLIC = 1;
  30. const GRANT_RESTRICTED = 2;
  31. const GRANT_SPECIFIED = 3; // DEPRECATED
  32. const GRANT_OWNER = 4;
  33. const GRANT_USER_GROUP = 5;
  34. const PAGE_GRANT_ERROR = 1;
  35. const STATUS_PUBLISHED = 'published';
  36. const STATUS_DELETED = 'deleted';
  37. export interface PageDocument extends IPage, Document {
  38. [x:string]: any // for obsolete methods
  39. getLatestRevisionBodyLength(): Promise<number | null | undefined>
  40. calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>
  41. }
  42. type TargetAndAncestorsResult = {
  43. targetAndAncestors: PageDocument[]
  44. rootPage: PageDocument
  45. }
  46. type PaginatedPages = {
  47. pages: PageDocument[],
  48. totalCount: number,
  49. limit: number,
  50. offset: number
  51. }
  52. export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
  53. export interface PageModel extends Model<PageDocument> {
  54. [x: string]: any; // for obsolete static methods
  55. findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
  56. findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
  57. findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
  58. findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
  59. countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
  60. findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
  61. findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
  62. generateGrantCondition(
  63. user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
  64. ): { $or: any[] }
  65. findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
  66. findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
  67. removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
  68. findTemplate(path: string): Promise<{
  69. templateBody?: string,
  70. templateTags?: string[],
  71. }>
  72. PageQueryBuilder: typeof PageQueryBuilder
  73. GRANT_PUBLIC
  74. GRANT_RESTRICTED
  75. GRANT_SPECIFIED
  76. GRANT_OWNER
  77. GRANT_USER_GROUP
  78. PAGE_GRANT_ERROR
  79. STATUS_PUBLISHED
  80. STATUS_DELETED
  81. }
  82. const ObjectId = mongoose.Schema.Types.ObjectId;
  83. const schema = new Schema<PageDocument, PageModel>({
  84. parent: {
  85. type: ObjectId, ref: 'Page', index: true, default: null,
  86. },
  87. descendantCount: { type: Number, default: 0 },
  88. isEmpty: { type: Boolean, default: false },
  89. path: {
  90. type: String, required: true, index: true,
  91. },
  92. revision: { type: ObjectId, ref: 'Revision' },
  93. latestRevisionBodyLength: { type: Number },
  94. status: { type: String, default: STATUS_PUBLISHED, index: true },
  95. grant: { type: Number, default: GRANT_PUBLIC, index: true },
  96. grantedUsers: [{ type: ObjectId, ref: 'User' }],
  97. grantedGroups: {
  98. type: [{
  99. type: {
  100. type: String,
  101. enum: Object.values(GroupType),
  102. required: true,
  103. default: 'UserGroup',
  104. },
  105. item: {
  106. type: ObjectId,
  107. refPath: 'grantedGroups.type',
  108. required: true,
  109. index: true,
  110. },
  111. }],
  112. validate: [function(arr) {
  113. if (arr == null) return true;
  114. const uniqueItemValues = new Set(arr.map(e => e.item));
  115. return arr.length === uniqueItemValues.size;
  116. }, 'grantedGroups contains non unique item'],
  117. default: [],
  118. required: true,
  119. },
  120. creator: { type: ObjectId, ref: 'User', index: true },
  121. lastUpdateUser: { type: ObjectId, ref: 'User' },
  122. liker: [{ type: ObjectId, ref: 'User' }],
  123. seenUsers: [{ type: ObjectId, ref: 'User' }],
  124. commentCount: { type: Number, default: 0 },
  125. expandContentWidth: { type: Boolean },
  126. updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
  127. deleteUser: { type: ObjectId, ref: 'User' },
  128. deletedAt: { type: Date },
  129. }, {
  130. timestamps: { createdAt: true, updatedAt: false },
  131. toJSON: { getters: true },
  132. toObject: { getters: true },
  133. });
  134. // apply plugins
  135. schema.plugin(mongoosePaginate);
  136. schema.plugin(uniqueValidator);
  137. export class PageQueryBuilder {
  138. query: any;
  139. constructor(query, includeEmpty = false) {
  140. this.query = query;
  141. if (!includeEmpty) {
  142. this.query = this.query
  143. .and({
  144. $or: [
  145. { isEmpty: false },
  146. { isEmpty: null }, // for v4 compatibility
  147. ],
  148. });
  149. }
  150. }
  151. /**
  152. * Used for filtering the pages at specified paths not to include unintentional pages.
  153. * @param pathsToFilter The paths to have additional filters as to be applicable
  154. * @returns PageQueryBuilder
  155. */
  156. addConditionToFilterByApplicableAncestors(pathsToFilter: string[]): PageQueryBuilder {
  157. this.query = this.query
  158. .and(
  159. {
  160. $or: [
  161. { path: '/' },
  162. { path: { $in: pathsToFilter }, grant: GRANT_PUBLIC, status: STATUS_PUBLISHED },
  163. { path: { $in: pathsToFilter }, parent: { $ne: null }, status: STATUS_PUBLISHED },
  164. { path: { $nin: pathsToFilter }, status: STATUS_PUBLISHED },
  165. ],
  166. },
  167. );
  168. return this;
  169. }
  170. addConditionToExcludeTrashed(): PageQueryBuilder {
  171. this.query = this.query
  172. .and({
  173. $or: [
  174. { status: null },
  175. { status: STATUS_PUBLISHED },
  176. ],
  177. });
  178. return this;
  179. }
  180. /**
  181. * generate the query to find the pages '{path}/*' and '{path}' self.
  182. * If top page, return without doing anything.
  183. */
  184. addConditionToListWithDescendants(path: string, option?): PageQueryBuilder {
  185. // No request is set for the top page
  186. if (isTopPage(path)) {
  187. return this;
  188. }
  189. const pathNormalized = normalizePath(path);
  190. const pathWithTrailingSlash = addTrailingSlash(path);
  191. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  192. this.query = this.query
  193. .and({
  194. $or: [
  195. { path: pathNormalized },
  196. { path: new RegExp(`^${startsPattern}`) },
  197. ],
  198. });
  199. return this;
  200. }
  201. /**
  202. * generate the query to find the pages '{path}/*' (exclude '{path}' self).
  203. */
  204. addConditionToListOnlyDescendants(path: string, option): PageQueryBuilder {
  205. // exclude the target page
  206. this.query = this.query.and({ path: { $ne: path } });
  207. if (isTopPage(path)) {
  208. return this;
  209. }
  210. const pathWithTrailingSlash = addTrailingSlash(path);
  211. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  212. this.query = this.query
  213. .and(
  214. { path: new RegExp(`^${startsPattern}`) },
  215. );
  216. return this;
  217. }
  218. addConditionToListOnlyAncestors(path: string): PageQueryBuilder {
  219. const pathNormalized = normalizePath(path);
  220. const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
  221. this.query = this.query
  222. // exclude the target page
  223. .and({ path: { $ne: path } })
  224. .and(
  225. { path: { $in: ancestorsPaths } },
  226. );
  227. return this;
  228. }
  229. /**
  230. * generate the query to find pages that start with `path`
  231. *
  232. * In normal case, returns '{path}/*' and '{path}' self.
  233. * If top page, return without doing anything.
  234. *
  235. * *option*
  236. * Left for backward compatibility
  237. */
  238. addConditionToListByStartWith(str: string): PageQueryBuilder {
  239. const path = normalizePath(str);
  240. // No request is set for the top page
  241. if (isTopPage(path)) {
  242. return this;
  243. }
  244. const startsPattern = escapeStringRegexp(path);
  245. this.query = this.query
  246. .and({ path: new RegExp(`^${startsPattern}`) });
  247. return this;
  248. }
  249. addConditionToListByNotStartWith(str: string): PageQueryBuilder {
  250. const path = normalizePath(str);
  251. // No request is set for the top page
  252. if (isTopPage(path)) {
  253. return this;
  254. }
  255. const startsPattern = escapeStringRegexp(str);
  256. this.query = this.query
  257. .and({ path: new RegExp(`^(?!${startsPattern}).*$`) });
  258. return this;
  259. }
  260. addConditionToListByMatch(str: string): PageQueryBuilder {
  261. // No request is set for "/"
  262. if (str === '/') {
  263. return this;
  264. }
  265. const match = escapeStringRegexp(str);
  266. this.query = this.query
  267. .and({ path: new RegExp(`^(?=.*${match}).*$`) });
  268. return this;
  269. }
  270. addConditionToListByNotMatch(str: string): PageQueryBuilder {
  271. // No request is set for "/"
  272. if (str === '/') {
  273. return this;
  274. }
  275. const match = escapeStringRegexp(str);
  276. this.query = this.query
  277. .and({ path: new RegExp(`^(?!.*${match}).*$`) });
  278. return this;
  279. }
  280. async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
  281. // determine UserGroup condition
  282. const userGroups = user != null ? [
  283. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  284. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  285. ] : null;
  286. const grantConditions: any[] = [
  287. { grant: null },
  288. { grant: GRANT_PUBLIC },
  289. ];
  290. if (user != null) {
  291. grantConditions.push(
  292. { grant: GRANT_OWNER, grantedUsers: user._id },
  293. );
  294. }
  295. if (userGroups != null && userGroups.length > 0) {
  296. grantConditions.push(
  297. {
  298. grant: GRANT_USER_GROUP,
  299. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  300. },
  301. );
  302. }
  303. this.query = this.query
  304. .and({
  305. $or: grantConditions,
  306. });
  307. return this;
  308. }
  309. async addConditionAsMigratablePages(user): Promise<PageQueryBuilder> {
  310. this.query = this.query
  311. .and({
  312. $or: [
  313. { grant: { $ne: GRANT_RESTRICTED } },
  314. { grant: { $ne: GRANT_SPECIFIED } },
  315. ],
  316. });
  317. this.addConditionAsRootOrNotOnTree();
  318. this.addConditionAsNonRootPage();
  319. this.addConditionToExcludeTrashed();
  320. await this.addConditionForParentNormalization(user);
  321. return this;
  322. }
  323. // add viewer condition to PageQueryBuilder instance
  324. async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
  325. const relatedUserGroups = (user != null && userGroups == null) ? [
  326. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  327. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  328. ] : userGroups;
  329. this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
  330. return this;
  331. }
  332. addConditionToFilteringByViewer(
  333. user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
  334. ): PageQueryBuilder {
  335. const condition = generateGrantCondition(user, userGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
  336. this.query = this.query
  337. .and(condition);
  338. return this;
  339. }
  340. addConditionForSystemDeletion(): PageQueryBuilder {
  341. const condition = generateGrantConditionForSystemDeletion();
  342. this.query = this.query.and(condition);
  343. return this;
  344. }
  345. addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
  346. this.query = this.query
  347. .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
  348. return this;
  349. }
  350. addConditionAsNonRootPage(): PageQueryBuilder {
  351. this.query = this.query.and({ path: { $ne: '/' } });
  352. return this;
  353. }
  354. addConditionAsRootOrNotOnTree(): PageQueryBuilder {
  355. this.query = this.query
  356. .and({ parent: null });
  357. return this;
  358. }
  359. addConditionAsOnTree(): PageQueryBuilder {
  360. this.query = this.query
  361. .and(
  362. {
  363. $or: [
  364. { parent: { $ne: null } },
  365. { path: '/' },
  366. ],
  367. },
  368. );
  369. return this;
  370. }
  371. /*
  372. * Add this condition when get any ancestor pages including the target's parent
  373. */
  374. addConditionToSortPagesByDescPath(): PageQueryBuilder {
  375. this.query = this.query.sort('-path');
  376. return this;
  377. }
  378. addConditionToSortPagesByAscPath(): PageQueryBuilder {
  379. this.query = this.query.sort('path');
  380. return this;
  381. }
  382. addConditionToMinimizeDataForRendering(): PageQueryBuilder {
  383. this.query = this.query.select('_id path isEmpty grant revision descendantCount');
  384. return this;
  385. }
  386. addConditionToListByPathsArray(paths): PageQueryBuilder {
  387. this.query = this.query
  388. .and({
  389. path: {
  390. $in: paths,
  391. },
  392. });
  393. return this;
  394. }
  395. addConditionToListByPageIdsArray(pageIds): PageQueryBuilder {
  396. this.query = this.query
  397. .and({
  398. _id: {
  399. $in: pageIds,
  400. },
  401. });
  402. return this;
  403. }
  404. addConditionToExcludeByPageIdsArray(pageIds): PageQueryBuilder {
  405. this.query = this.query
  406. .and({
  407. _id: {
  408. $nin: pageIds,
  409. },
  410. });
  411. return this;
  412. }
  413. populateDataToList(userPublicFields): PageQueryBuilder {
  414. this.query = this.query
  415. .populate({
  416. path: 'lastUpdateUser',
  417. select: userPublicFields,
  418. });
  419. return this;
  420. }
  421. populateDataToShowRevision(userPublicFields): PageQueryBuilder {
  422. this.query = populateDataToShowRevision(this.query, userPublicFields);
  423. return this;
  424. }
  425. addConditionToFilteringByParentId(parentId): PageQueryBuilder {
  426. this.query = this.query.and({ parent: parentId });
  427. return this;
  428. }
  429. }
  430. schema.statics.createEmptyPage = async function(
  431. path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
  432. ): Promise<PageDocument & { _id: any }> {
  433. if (parent == null) {
  434. throw Error('parent must not be null');
  435. }
  436. const Page = this;
  437. const page = new Page();
  438. page.path = path;
  439. page.isEmpty = true;
  440. page.parent = parent;
  441. page.descendantCount = descendantCount;
  442. return page.save();
  443. };
  444. /**
  445. * Replace an existing page with an empty page.
  446. * It updates the children's parent to the new empty page's _id.
  447. * @param exPage a page document to be replaced
  448. * @returns Promise<void>
  449. */
  450. schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?, deleteExPageIfEmpty = false) {
  451. // find parent
  452. const parent = await this.findOne({ _id: exPage.parent });
  453. if (parent == null) {
  454. throw Error('parent to update does not exist. Prepare parent first.');
  455. }
  456. // create empty page at path
  457. const newTarget = pageToReplaceWith == null ? await this.createEmptyPage(exPage.path, parent, exPage.descendantCount) : pageToReplaceWith;
  458. // find children by ex-page _id
  459. const children = await this.find({ parent: exPage._id });
  460. // bulkWrite
  461. const operationForNewTarget = {
  462. updateOne: {
  463. filter: { _id: newTarget._id },
  464. update: {
  465. parent: parent._id,
  466. },
  467. },
  468. };
  469. const operationsForChildren = {
  470. updateMany: {
  471. filter: {
  472. _id: { $in: children.map(d => d._id) },
  473. },
  474. update: {
  475. parent: newTarget._id,
  476. },
  477. },
  478. };
  479. await this.bulkWrite([operationForNewTarget, operationsForChildren]);
  480. const isExPageEmpty = exPage.isEmpty;
  481. if (deleteExPageIfEmpty && isExPageEmpty) {
  482. await this.deleteOne({ _id: exPage._id });
  483. logger.warn('Deleted empty page since it was replaced with another page.');
  484. }
  485. return this.findById(newTarget._id);
  486. };
  487. /*
  488. * Find pages by ID and viewer.
  489. */
  490. schema.statics.findByIdsAndViewer = async function(
  491. pageIds: string[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
  492. ): Promise<PageDocument[]> {
  493. const baseQuery = this.find({ _id: { $in: pageIds } });
  494. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  495. await queryBuilder.addViewerCondition(user, userGroups, includeAnyoneWithTheLink);
  496. return queryBuilder.query.exec();
  497. };
  498. /*
  499. * Find a page by path and viewer. Pass true to useFindOne to use findOne method.
  500. */
  501. schema.statics.findByPathAndViewer = async function(
  502. path: string | null, user, userGroups = null, useFindOne = false, includeEmpty = false,
  503. ): Promise<(PageDocument | PageDocument[]) & HasObjectId | null> {
  504. if (path == null) {
  505. throw new Error('path is required.');
  506. }
  507. const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
  508. const includeAnyoneWithTheLink = useFindOne;
  509. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  510. await queryBuilder.addViewerCondition(user, userGroups, includeAnyoneWithTheLink);
  511. return queryBuilder.query.exec();
  512. };
  513. schema.statics.countByPathAndViewer = async function(path: string | null, user, userGroups = null, includeEmpty = false): Promise<number> {
  514. if (path == null) {
  515. throw new Error('path is required.');
  516. }
  517. const baseQuery = this.count({ path });
  518. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  519. await queryBuilder.addViewerCondition(user, userGroups);
  520. return queryBuilder.query.exec();
  521. };
  522. schema.statics.findRecentUpdatedPages = async function(
  523. path: string, user, options, includeEmpty = false,
  524. ): Promise<PaginatedPages> {
  525. const sortOpt = {};
  526. sortOpt[options.sort] = options.desc;
  527. const Page = this;
  528. const User = mongoose.model('User') as any;
  529. if (path == null) {
  530. throw new Error('path is required.');
  531. }
  532. const baseQuery = this.find({});
  533. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  534. if (!options.includeTrashed) {
  535. queryBuilder.addConditionToExcludeTrashed();
  536. }
  537. queryBuilder.addConditionToListWithDescendants(path, options);
  538. queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
  539. await queryBuilder.addViewerCondition(user);
  540. const pages = await Page.paginate(queryBuilder.query.clone(), {
  541. lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
  542. });
  543. const results = {
  544. pages: pages.docs, totalCount: pages.totalDocs, offset: options.offset, limit: options.limit,
  545. };
  546. return results;
  547. };
  548. /*
  549. * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  550. * The result will include the target as well
  551. */
  552. schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string, user, userGroups): Promise<TargetAndAncestorsResult> {
  553. let path;
  554. if (!hasSlash(pathOrId)) {
  555. const _id = pathOrId;
  556. const page = await this.findOne({ _id });
  557. path = page == null ? '/' : page.path;
  558. }
  559. else {
  560. path = pathOrId;
  561. }
  562. const ancestorPaths = collectAncestorPaths(path);
  563. ancestorPaths.push(path); // include target
  564. // Do not populate
  565. const queryBuilder = new PageQueryBuilder(this.find(), true);
  566. await queryBuilder.addViewerCondition(user, userGroups);
  567. const _targetAndAncestors: PageDocument[] = await queryBuilder
  568. .addConditionAsOnTree()
  569. .addConditionToListByPathsArray(ancestorPaths)
  570. .addConditionToMinimizeDataForRendering()
  571. .addConditionToSortPagesByDescPath()
  572. .query
  573. .lean()
  574. .exec();
  575. // no same path pages
  576. const ancestorsMap = new Map<string, PageDocument>();
  577. _targetAndAncestors.forEach(page => ancestorsMap.set(page.path, page));
  578. const targetAndAncestors = Array.from(ancestorsMap.values());
  579. const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
  580. return { targetAndAncestors, rootPage };
  581. };
  582. /**
  583. * Create empty pages at paths at which no pages exist
  584. * @param paths Page paths
  585. * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
  586. */
  587. schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPipelineForExistingPages: any[]): Promise<void> {
  588. const existingPages = await this.aggregate(aggrPipelineForExistingPages);
  589. const existingPagePaths = existingPages.map(page => page.path);
  590. const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
  591. await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
  592. };
  593. /**
  594. * Find a parent page by path
  595. * @param {string} path
  596. * @returns {Promise<PageDocument | null>}
  597. */
  598. schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
  599. const parentPath = nodePath.dirname(path);
  600. const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
  601. const pagesCanBeParent = await builder
  602. .addConditionAsOnTree()
  603. .query
  604. .exec();
  605. if (pagesCanBeParent.length >= 1) {
  606. return pagesCanBeParent[0]; // the earliest page will be the result
  607. }
  608. return null;
  609. };
  610. /*
  611. * Utils from obsolete-page.js
  612. */
  613. export async function pushRevision(pageData, newRevision, user) {
  614. await newRevision.save();
  615. pageData.revision = newRevision;
  616. pageData.latestRevisionBodyLength = newRevision.body.length;
  617. pageData.lastUpdateUser = user?._id ?? user;
  618. pageData.updatedAt = Date.now();
  619. return pageData.save();
  620. }
  621. /**
  622. * add/subtract descendantCount of pages with provided paths by increment.
  623. * increment can be negative number
  624. */
  625. schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: ObjectIdLike[], increment: number): Promise<void> {
  626. await this.updateMany({ _id: { $in: pageIds } }, { $inc: { descendantCount: increment } });
  627. };
  628. /**
  629. * recount descendantCount of a page with the provided id and return it
  630. */
  631. schema.statics.recountDescendantCount = async function(id: ObjectIdLike): Promise<number> {
  632. const res = await this.aggregate(
  633. [
  634. {
  635. $match: {
  636. parent: id,
  637. },
  638. },
  639. {
  640. $project: {
  641. parent: 1,
  642. isEmpty: 1,
  643. descendantCount: 1,
  644. },
  645. },
  646. {
  647. $group: {
  648. _id: '$parent',
  649. sumOfDescendantCount: {
  650. $sum: '$descendantCount',
  651. },
  652. sumOfDocsCount: {
  653. $sum: {
  654. $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
  655. },
  656. },
  657. },
  658. },
  659. {
  660. $set: {
  661. descendantCount: {
  662. $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
  663. },
  664. },
  665. },
  666. ],
  667. );
  668. return res.length === 0 ? 0 : res[0].descendantCount;
  669. };
  670. schema.statics.findAncestorsUsingParentRecursively = async function(pageId: ObjectIdLike, shouldIncludeTarget: boolean) {
  671. const self = this;
  672. const target = await this.findById(pageId);
  673. if (target == null) {
  674. throw Error('Target not found');
  675. }
  676. async function findAncestorsRecursively(target, ancestors = shouldIncludeTarget ? [target] : []) {
  677. const parent = await self.findOne({ _id: target.parent });
  678. if (parent == null) {
  679. return ancestors;
  680. }
  681. return findAncestorsRecursively(parent, [...ancestors, parent]);
  682. }
  683. return findAncestorsRecursively(target);
  684. };
  685. // TODO: write test code
  686. /**
  687. * Recursively removes empty pages at leaf position.
  688. * @param pageId ObjectIdLike
  689. * @returns Promise<void>
  690. */
  691. schema.statics.removeLeafEmptyPagesRecursively = async function(pageId: ObjectIdLike): Promise<void> {
  692. const self = this;
  693. const initialPage = await this.findById(pageId);
  694. if (initialPage == null) {
  695. return;
  696. }
  697. if (!initialPage.isEmpty) {
  698. return;
  699. }
  700. async function generatePageIdsToRemove(childPage, page, pageIds: ObjectIdLike[] = []): Promise<ObjectIdLike[]> {
  701. if (!page.isEmpty) {
  702. return pageIds;
  703. }
  704. const isChildrenOtherThanTargetExist = await self.exists({ _id: { $ne: childPage?._id }, parent: page._id });
  705. if (isChildrenOtherThanTargetExist) {
  706. return pageIds;
  707. }
  708. pageIds.push(page._id);
  709. const nextPage = await self.findById(page.parent);
  710. if (nextPage == null) {
  711. return pageIds;
  712. }
  713. return generatePageIdsToRemove(page, nextPage, pageIds);
  714. }
  715. const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
  716. await this.deleteMany({ _id: { $in: pageIdsToRemove } });
  717. };
  718. schema.statics.normalizeDescendantCountById = async function(pageId) {
  719. const children = await this.find({ parent: pageId });
  720. const sumChildrenDescendantCount = children.map(d => d.descendantCount).reduce((c1, c2) => c1 + c2);
  721. const sumChildPages = children.filter(p => !p.isEmpty).length;
  722. return this.updateOne({ _id: pageId }, { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } }, { new: true });
  723. };
  724. schema.statics.takeOffFromTree = async function(pageId: ObjectIdLike) {
  725. return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
  726. };
  727. schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLike[], paths: string[]): Promise<void> {
  728. await this.deleteMany({
  729. _id: {
  730. $nin: pageIdsToNotRemove,
  731. },
  732. path: {
  733. $in: paths,
  734. },
  735. isEmpty: true,
  736. });
  737. };
  738. /**
  739. * Find a not empty parent recursively.
  740. * @param {string} path
  741. * @returns {Promise<PageDocument | null>}
  742. */
  743. schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
  744. const parent = await this.findParentByPath(path);
  745. if (parent == null) {
  746. return null;
  747. }
  748. const recursive = async(page: PageDocument): Promise<PageDocument> => {
  749. if (!page.isEmpty) {
  750. return page;
  751. }
  752. const next = await this.findById(page.parent);
  753. if (next == null || isTopPage(next.path)) {
  754. return page;
  755. }
  756. return recursive(next);
  757. };
  758. const notEmptyParent = await recursive(parent);
  759. return notEmptyParent;
  760. };
  761. schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
  762. return this.findOne({ _id: pageId });
  763. };
  764. schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
  765. export function generateGrantCondition(
  766. user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
  767. ): { $or: any[] } {
  768. const grantConditions: AnyObject[] = [
  769. { grant: null },
  770. { grant: GRANT_PUBLIC },
  771. ];
  772. if (includeAnyoneWithTheLink) {
  773. grantConditions.push({ grant: GRANT_RESTRICTED });
  774. }
  775. if (showPagesRestrictedByOwner) {
  776. grantConditions.push(
  777. { grant: GRANT_SPECIFIED },
  778. { grant: GRANT_OWNER },
  779. );
  780. }
  781. else if (user != null) {
  782. grantConditions.push(
  783. { grant: GRANT_SPECIFIED, grantedUsers: user._id },
  784. { grant: GRANT_OWNER, grantedUsers: user._id },
  785. );
  786. }
  787. if (showPagesRestrictedByGroup) {
  788. grantConditions.push(
  789. { grant: GRANT_USER_GROUP },
  790. );
  791. }
  792. else if (userGroups != null && userGroups.length > 0) {
  793. grantConditions.push(
  794. {
  795. grant: GRANT_USER_GROUP,
  796. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  797. },
  798. );
  799. }
  800. return {
  801. $or: grantConditions,
  802. };
  803. }
  804. schema.statics.generateGrantCondition = generateGrantCondition;
  805. function generateGrantConditionForSystemDeletion(): { $or: any[] } {
  806. const grantCondition: AnyObject[] = [
  807. { grant: null },
  808. { grant: GRANT_PUBLIC },
  809. { grant: GRANT_RESTRICTED },
  810. { grant: GRANT_SPECIFIED },
  811. { grant: GRANT_OWNER },
  812. { grant: GRANT_USER_GROUP },
  813. ];
  814. return {
  815. $or: grantCondition,
  816. };
  817. }
  818. schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
  819. // find ancestor page with isEmpty: false. If parameter path is '/', return undefined
  820. schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
  821. if (path === '/') {
  822. return;
  823. }
  824. const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
  825. const ancestors = await builderForAncestors
  826. .addConditionToListOnlyAncestors(path) // only ancestor paths
  827. .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
  828. .query
  829. .exec();
  830. return ancestors[0];
  831. };
  832. /*
  833. * get latest revision body length
  834. */
  835. schema.methods.getLatestRevisionBodyLength = async function(this: PageDocument): Promise<number | null | undefined> {
  836. if (!this.isLatestRevision() || this.revision == null) {
  837. return null;
  838. }
  839. if (this.latestRevisionBodyLength == null) {
  840. await this.calculateAndUpdateLatestRevisionBodyLength();
  841. }
  842. return this.latestRevisionBodyLength;
  843. };
  844. /*
  845. * calculate and update latestRevisionBodyLength
  846. */
  847. schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this: PageDocument): Promise<void> {
  848. if (!this.isLatestRevision() || this.revision == null) {
  849. logger.error('revision field is required.');
  850. return;
  851. }
  852. // eslint-disable-next-line rulesdir/no-populate
  853. const populatedPageDocument = await this.populate<PageDocument>('revision', 'body');
  854. assert(populatedPageDocument.revision != null);
  855. assert(isPopulated(populatedPageDocument.revision));
  856. this.latestRevisionBodyLength = populatedPageDocument.revision.body.length;
  857. await this.save();
  858. };
  859. /*
  860. * Merge obsolete page model methods and define new methods which depend on crowi instance
  861. */
  862. export default function PageModel(crowi): any {
  863. // add old page schema methods
  864. const pageSchema = getPageSchema(crowi);
  865. schema.methods = { ...pageSchema.methods, ...schema.methods };
  866. schema.statics = { ...pageSchema.statics, ...schema.statics };
  867. return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
  868. }