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