page.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import { GroupType, type HasObjectId, type IPage } from '@growi/core';
  3. import type {
  4. IPagePopulatedToShowRevision,
  5. IUserHasId,
  6. } from '@growi/core/dist/interfaces';
  7. import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
  8. import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
  9. import {
  10. addTrailingSlash,
  11. normalizePath,
  12. } from '@growi/core/dist/utils/path-utils';
  13. import assert from 'assert';
  14. import escapeStringRegexp from 'escape-string-regexp';
  15. import type {
  16. AnyObject,
  17. Document,
  18. HydratedDocument,
  19. Model,
  20. Types,
  21. } from 'mongoose';
  22. import mongoose, { Schema } from 'mongoose';
  23. import mongoosePaginate from 'mongoose-paginate-v2';
  24. import uniqueValidator from 'mongoose-unique-validator';
  25. import nodePath from 'path';
  26. import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
  27. import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
  28. import type {
  29. IOptionsForCreate,
  30. IPagePathWithDescendantCount,
  31. } from '~/interfaces/page';
  32. import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
  33. import loggerFactory from '../../utils/logger';
  34. import type Crowi from '../crowi';
  35. import { collectAncestorPaths } from '../util/collect-ancestor-paths';
  36. import { getOrCreateModel } from '../util/mongoose-utils';
  37. import {
  38. extractToAncestorsPaths,
  39. getPageSchema,
  40. populateDataToShowRevision,
  41. } from './obsolete-page';
  42. import type { UserGroupDocument } from './user-group';
  43. import UserGroupRelation from './user-group-relation';
  44. const logger = loggerFactory('growi:models:page');
  45. /*
  46. * define schema
  47. */
  48. const GRANT_PUBLIC = 1;
  49. const GRANT_RESTRICTED = 2;
  50. const GRANT_SPECIFIED = 3; // DEPRECATED
  51. const GRANT_OWNER = 4;
  52. const GRANT_USER_GROUP = 5;
  53. const PAGE_GRANT_ERROR = 1;
  54. const STATUS_PUBLISHED = 'published';
  55. const STATUS_DELETED = 'deleted';
  56. export interface PageDocument extends IPage, Document<Types.ObjectId> {
  57. [x: string]: any; // for obsolete methods
  58. getLatestRevisionBodyLength(): Promise<number | null | undefined>;
  59. calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>;
  60. populateDataToShowRevision(
  61. shouldExcludeBody?: boolean,
  62. ): Promise<IPagePopulatedToShowRevision & PageDocument>;
  63. }
  64. type TargetAndAncestorsResult = {
  65. targetAndAncestors: PageDocument[];
  66. rootPage: PageDocument;
  67. };
  68. type PaginatedPages = {
  69. pages: PageDocument[];
  70. totalCount: number;
  71. limit: number;
  72. offset: number;
  73. };
  74. export type FindRecentUpdatedPagesOption = {
  75. offset: number;
  76. limit: number;
  77. includeWipPage: boolean;
  78. includeTrashed: boolean;
  79. isRegExpEscapedFromPath: boolean;
  80. sort: 'updatedAt';
  81. desc: number;
  82. hideRestrictedByOwner: boolean;
  83. hideRestrictedByGroup: boolean;
  84. };
  85. export type CreateMethod = (
  86. path: string,
  87. body: string,
  88. user,
  89. options: IOptionsForCreate,
  90. ) => Promise<HydratedDocument<PageDocument>>;
  91. export interface PageModel extends Model<PageDocument> {
  92. [x: string]: any; // for obsolete static methods
  93. createEmptyPage(
  94. path: string,
  95. parent,
  96. descendantCount?: number,
  97. ): Promise<HydratedDocument<PageDocument>>;
  98. findByIdAndViewer(
  99. pageId: ObjectIdLike,
  100. user,
  101. userGroups?,
  102. includeEmpty?: boolean,
  103. ): Promise<HydratedDocument<PageDocument> | null>;
  104. findByIdsAndViewer(
  105. pageIds: ObjectIdLike[],
  106. user,
  107. userGroups?,
  108. includeEmpty?: boolean,
  109. includeAnyoneWithTheLink?: boolean,
  110. ): Promise<HydratedDocument<PageDocument>[]>;
  111. findByPath(
  112. path: string,
  113. includeEmpty?: boolean,
  114. ): Promise<HydratedDocument<PageDocument> | null>;
  115. findByPathAndViewer(
  116. path: string | null,
  117. user,
  118. userGroups?,
  119. useFindOne?: true,
  120. includeEmpty?: boolean,
  121. ): Promise<HydratedDocument<PageDocument> | null>;
  122. findByPathAndViewer(
  123. path: string | null,
  124. user,
  125. userGroups?,
  126. useFindOne?: false,
  127. includeEmpty?: boolean,
  128. ): Promise<HydratedDocument<PageDocument>[]>;
  129. descendantCountByPaths(
  130. paths: string[],
  131. user: IUserHasId,
  132. userGroups?,
  133. includeEmpty?: boolean,
  134. includeAnyoneWithTheLink?: boolean,
  135. ): Promise<IPagePathWithDescendantCount[]>;
  136. findParentByPath(
  137. path: string | null,
  138. ): Promise<HydratedDocument<PageDocument> | null>;
  139. findTargetAndAncestorsByPathOrId(
  140. pathOrId: string,
  141. ): Promise<TargetAndAncestorsResult>;
  142. findRecentUpdatedPages(
  143. path: string,
  144. user,
  145. option: FindRecentUpdatedPagesOption,
  146. includeEmpty?: boolean,
  147. ): Promise<PaginatedPages>;
  148. generateGrantCondition(
  149. user,
  150. userGroups: ObjectIdLike[] | null,
  151. includeAnyoneWithTheLink?: boolean,
  152. showPagesRestrictedByOwner?: boolean,
  153. showPagesRestrictedByGroup?: boolean,
  154. ): { $or: any[] };
  155. findNonEmptyClosestAncestor(
  156. path: string,
  157. ): Promise<HydratedDocument<PageDocument> | null>;
  158. findNotEmptyParentByPathRecursively(
  159. path: string,
  160. ): Promise<HydratedDocument<PageDocument> | null>;
  161. removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>;
  162. findTemplate(path: string): Promise<{
  163. templateBody?: string;
  164. templateTags?: string[];
  165. }>;
  166. removeGroupsToDeleteFromPages(
  167. pages: PageDocument[],
  168. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  169. ): Promise<void>;
  170. PageQueryBuilder: typeof PageQueryBuilder;
  171. GRANT_PUBLIC;
  172. GRANT_RESTRICTED;
  173. GRANT_SPECIFIED;
  174. GRANT_OWNER;
  175. GRANT_USER_GROUP;
  176. PAGE_GRANT_ERROR;
  177. STATUS_PUBLISHED;
  178. STATUS_DELETED;
  179. }
  180. const schema = new Schema<PageDocument, PageModel>(
  181. {
  182. parent: {
  183. type: Schema.Types.ObjectId,
  184. ref: 'Page',
  185. index: true,
  186. default: null,
  187. },
  188. descendantCount: { type: Number, default: 0 },
  189. isEmpty: { type: Boolean, default: false },
  190. path: {
  191. type: String,
  192. required: true,
  193. index: true,
  194. },
  195. revision: { type: Schema.Types.ObjectId, ref: 'Revision' },
  196. latestRevisionBodyLength: { type: Number },
  197. status: { type: String, default: STATUS_PUBLISHED, index: true },
  198. grant: { type: Number, default: GRANT_PUBLIC, index: true },
  199. grantedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  200. grantedGroups: {
  201. type: [
  202. {
  203. type: {
  204. type: String,
  205. enum: Object.values(GroupType),
  206. required: true,
  207. default: 'UserGroup',
  208. },
  209. item: {
  210. type: Schema.Types.ObjectId,
  211. refPath: 'grantedGroups.type',
  212. required: true,
  213. index: true,
  214. },
  215. },
  216. ],
  217. validate: [
  218. (arr) => {
  219. if (arr == null) return true;
  220. const uniqueItemValues = new Set(arr.map((e) => e.item));
  221. return arr.length === uniqueItemValues.size;
  222. },
  223. 'grantedGroups contains non unique item',
  224. ],
  225. default: [],
  226. required: true,
  227. },
  228. creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
  229. lastUpdateUser: { type: Schema.Types.ObjectId, ref: 'User' },
  230. liker: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  231. seenUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  232. commentCount: { type: Number, default: 0 },
  233. expandContentWidth: { type: Boolean },
  234. wip: { type: Boolean },
  235. ttlTimestamp: { type: Date },
  236. updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
  237. deleteUser: { type: Schema.Types.ObjectId, ref: 'User' },
  238. deletedAt: { type: Date },
  239. },
  240. {
  241. timestamps: { createdAt: true, updatedAt: false },
  242. toJSON: { getters: true },
  243. toObject: { getters: true },
  244. },
  245. );
  246. // indexes
  247. schema.index({ createdAt: 1 });
  248. schema.index({ updatedAt: 1 });
  249. // apply plugins
  250. schema.plugin(mongoosePaginate);
  251. schema.plugin(uniqueValidator);
  252. export class PageQueryBuilder {
  253. query: any;
  254. constructor(query, includeEmpty = false) {
  255. this.query = query;
  256. if (!includeEmpty) {
  257. this.query = this.query.and({
  258. $or: [
  259. { isEmpty: false },
  260. { isEmpty: null }, // for v4 compatibility
  261. ],
  262. });
  263. }
  264. }
  265. /**
  266. * Used for filtering the pages at specified paths not to include unintentional pages.
  267. * @param pathsToFilter The paths to have additional filters as to be applicable
  268. * @returns PageQueryBuilder
  269. */
  270. addConditionToFilterByApplicableAncestors(
  271. pathsToFilter: string[],
  272. ): PageQueryBuilder {
  273. this.query = this.query.and({
  274. $or: [
  275. { path: '/' },
  276. {
  277. path: { $in: pathsToFilter },
  278. grant: GRANT_PUBLIC,
  279. status: STATUS_PUBLISHED,
  280. },
  281. {
  282. path: { $in: pathsToFilter },
  283. parent: { $ne: null },
  284. status: STATUS_PUBLISHED,
  285. },
  286. { path: { $nin: pathsToFilter }, status: STATUS_PUBLISHED },
  287. ],
  288. });
  289. return this;
  290. }
  291. addConditionToExcludeTrashed(): PageQueryBuilder {
  292. this.query = this.query.and({
  293. $or: [{ status: null }, { status: STATUS_PUBLISHED }],
  294. });
  295. return this;
  296. }
  297. addConditionToExcludeWipPage(): PageQueryBuilder {
  298. this.query = this.query.and({
  299. $or: [{ wip: undefined }, { wip: false }],
  300. });
  301. return this;
  302. }
  303. /**
  304. * generate the query to find the pages '{path}/*' and '{path}' self.
  305. * If top page, return without doing anything.
  306. */
  307. addConditionToListWithDescendants(path: string, option?): PageQueryBuilder {
  308. // No request is set for the top page
  309. if (isTopPage(path)) {
  310. return this;
  311. }
  312. const pathNormalized = normalizePath(path);
  313. const pathWithTrailingSlash = addTrailingSlash(path);
  314. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  315. this.query = this.query.and({
  316. $or: [
  317. { path: pathNormalized },
  318. { path: new RegExp(`^${startsPattern}`) },
  319. ],
  320. });
  321. return this;
  322. }
  323. /**
  324. * generate the query to find the pages '{path}/*' (exclude '{path}' self).
  325. */
  326. addConditionToListOnlyDescendants(path: string): PageQueryBuilder {
  327. // exclude the target page
  328. this.query = this.query.and({ path: { $ne: path } });
  329. if (isTopPage(path)) {
  330. return this;
  331. }
  332. const pathWithTrailingSlash = addTrailingSlash(path);
  333. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  334. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  335. return this;
  336. }
  337. addConditionToListOnlyAncestors(path: string): PageQueryBuilder {
  338. const pathNormalized = normalizePath(path);
  339. const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
  340. this.query = this.query
  341. // exclude the target page
  342. .and({ path: { $ne: path } })
  343. .and({ path: { $in: ancestorsPaths } });
  344. return this;
  345. }
  346. /**
  347. * generate the query to find pages that start with `path`
  348. *
  349. * In normal case, returns '{path}/*' and '{path}' self.
  350. * If top page, return without doing anything.
  351. *
  352. * *option*
  353. * Left for backward compatibility
  354. */
  355. addConditionToListByStartWith(str: string): PageQueryBuilder {
  356. const path = normalizePath(str);
  357. // No request is set for the top page
  358. if (isTopPage(path)) {
  359. return this;
  360. }
  361. const startsPattern = escapeStringRegexp(path);
  362. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  363. return this;
  364. }
  365. addConditionToListByNotStartWith(str: string): PageQueryBuilder {
  366. const path = normalizePath(str);
  367. // No request is set for the top page
  368. if (isTopPage(path)) {
  369. return this;
  370. }
  371. const startsPattern = escapeStringRegexp(str);
  372. this.query = this.query.and({
  373. path: new RegExp(`^(?!${startsPattern}).*$`),
  374. });
  375. return this;
  376. }
  377. addConditionToListByMatch(str: string): PageQueryBuilder {
  378. // No request is set for "/"
  379. if (str === '/') {
  380. return this;
  381. }
  382. const match = escapeStringRegexp(str);
  383. this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
  384. return this;
  385. }
  386. addConditionToListByNotMatch(str: string): PageQueryBuilder {
  387. // No request is set for "/"
  388. if (str === '/') {
  389. return this;
  390. }
  391. const match = escapeStringRegexp(str);
  392. this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
  393. return this;
  394. }
  395. async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
  396. // determine UserGroup condition
  397. const userGroups =
  398. user != null
  399. ? [
  400. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  401. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  402. user,
  403. )),
  404. ]
  405. : null;
  406. const grantConditions: any[] = [{ grant: null }, { grant: GRANT_PUBLIC }];
  407. if (user != null) {
  408. grantConditions.push({ grant: GRANT_OWNER, grantedUsers: user._id });
  409. }
  410. if (userGroups != null && userGroups.length > 0) {
  411. grantConditions.push({
  412. grant: GRANT_USER_GROUP,
  413. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  414. });
  415. }
  416. this.query = this.query.and({
  417. $or: grantConditions,
  418. });
  419. return this;
  420. }
  421. async addConditionAsMigratablePages(user): Promise<PageQueryBuilder> {
  422. this.query = this.query.and({
  423. $or: [
  424. { grant: { $ne: GRANT_RESTRICTED } },
  425. { grant: { $ne: GRANT_SPECIFIED } },
  426. ],
  427. });
  428. this.addConditionAsRootOrNotOnTree();
  429. this.addConditionAsNonRootPage();
  430. this.addConditionToExcludeTrashed();
  431. await this.addConditionForParentNormalization(user);
  432. return this;
  433. }
  434. // add viewer condition to PageQueryBuilder instance
  435. async addViewerCondition(
  436. user,
  437. userGroups = null,
  438. includeAnyoneWithTheLink = false,
  439. showPagesRestrictedByOwner = false,
  440. showPagesRestrictedByGroup = false,
  441. ): Promise<PageQueryBuilder> {
  442. const relatedUserGroups =
  443. user != null && userGroups == null
  444. ? [
  445. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  446. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  447. user,
  448. )),
  449. ]
  450. : userGroups;
  451. this.addConditionToFilteringByViewer(
  452. user,
  453. relatedUserGroups,
  454. includeAnyoneWithTheLink,
  455. showPagesRestrictedByOwner,
  456. showPagesRestrictedByGroup,
  457. );
  458. return this;
  459. }
  460. addConditionToFilteringByViewer(
  461. user,
  462. userGroups: ObjectIdLike[] | null,
  463. includeAnyoneWithTheLink = false,
  464. showPagesRestrictedByOwner = false,
  465. showPagesRestrictedByGroup = false,
  466. ): PageQueryBuilder {
  467. const condition = generateGrantCondition(
  468. user,
  469. userGroups,
  470. includeAnyoneWithTheLink,
  471. showPagesRestrictedByOwner,
  472. showPagesRestrictedByGroup,
  473. );
  474. this.query = this.query.and(condition);
  475. return this;
  476. }
  477. addConditionForSystemDeletion(): PageQueryBuilder {
  478. const condition = generateGrantConditionForSystemDeletion();
  479. this.query = this.query.and(condition);
  480. return this;
  481. }
  482. addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
  483. this.query = this.query.sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
  484. return this;
  485. }
  486. addConditionAsNonRootPage(): PageQueryBuilder {
  487. this.query = this.query.and({ path: { $ne: '/' } });
  488. return this;
  489. }
  490. addConditionAsRootOrNotOnTree(): PageQueryBuilder {
  491. this.query = this.query.and({ parent: null });
  492. return this;
  493. }
  494. addConditionAsOnTree(): PageQueryBuilder {
  495. this.query = this.query.and({
  496. $or: [{ parent: { $ne: null } }, { path: '/' }],
  497. });
  498. return this;
  499. }
  500. /*
  501. * Add this condition when get any ancestor pages including the target's parent
  502. */
  503. addConditionToSortPagesByDescPath(): PageQueryBuilder {
  504. this.query = this.query.sort('-path');
  505. return this;
  506. }
  507. addConditionToSortPagesByAscPath(): PageQueryBuilder {
  508. this.query = this.query.sort('path');
  509. return this;
  510. }
  511. addConditionToMinimizeDataForRendering(): PageQueryBuilder {
  512. this.query = this.query.select(
  513. '_id path isEmpty grant revision descendantCount',
  514. );
  515. return this;
  516. }
  517. addConditionToListByPathsArray(paths): PageQueryBuilder {
  518. this.query = this.query.and({
  519. path: {
  520. $in: paths,
  521. },
  522. });
  523. return this;
  524. }
  525. addConditionToListByPageIdsArray(pageIds): PageQueryBuilder {
  526. this.query = this.query.and({
  527. _id: {
  528. $in: pageIds,
  529. },
  530. });
  531. return this;
  532. }
  533. addConditionToExcludeByPageIdsArray(pageIds): PageQueryBuilder {
  534. this.query = this.query.and({
  535. _id: {
  536. $nin: pageIds,
  537. },
  538. });
  539. return this;
  540. }
  541. populateDataToList(userPublicFields): PageQueryBuilder {
  542. this.query = this.query.populate({
  543. path: 'lastUpdateUser',
  544. select: userPublicFields,
  545. });
  546. return this;
  547. }
  548. populateDataToShowRevision(userPublicFields): PageQueryBuilder {
  549. this.query = populateDataToShowRevision(this.query, userPublicFields);
  550. return this;
  551. }
  552. addConditionToFilteringByParentId(parentId): PageQueryBuilder {
  553. this.query = this.query.and({ parent: parentId });
  554. return this;
  555. }
  556. }
  557. schema.statics.createEmptyPage = async function (
  558. path: string,
  559. parent: any,
  560. descendantCount = 0,
  561. ): Promise<HydratedDocument<PageDocument>> {
  562. if (parent == null) {
  563. throw Error('parent must not be null');
  564. }
  565. const page = new this();
  566. page.path = path;
  567. page.isEmpty = true;
  568. page.parent = parent;
  569. page.descendantCount = descendantCount;
  570. return page.save();
  571. };
  572. /**
  573. * Replace an existing page with an empty page.
  574. * It updates the children's parent to the new empty page's _id.
  575. * @param exPage a page document to be replaced
  576. * @returns Promise<void>
  577. */
  578. schema.statics.replaceTargetWithPage = async function (
  579. exPage,
  580. pageToReplaceWith?,
  581. deleteExPageIfEmpty = false,
  582. ) {
  583. // find parent
  584. const parent = await this.findOne({ _id: exPage.parent });
  585. if (parent == null) {
  586. throw Error('parent to update does not exist. Prepare parent first.');
  587. }
  588. // create empty page at path
  589. const newTarget =
  590. pageToReplaceWith == null
  591. ? await this.createEmptyPage(exPage.path, parent, exPage.descendantCount)
  592. : pageToReplaceWith;
  593. // find children by ex-page _id
  594. const children = await this.find({ parent: exPage._id });
  595. // bulkWrite
  596. const operationForNewTarget = {
  597. updateOne: {
  598. filter: { _id: newTarget._id },
  599. update: {
  600. parent: parent._id,
  601. },
  602. },
  603. };
  604. const operationsForChildren = {
  605. updateMany: {
  606. filter: {
  607. _id: { $in: children.map((d) => d._id) },
  608. },
  609. update: {
  610. parent: newTarget._id,
  611. },
  612. },
  613. };
  614. await this.bulkWrite([operationForNewTarget, operationsForChildren]);
  615. const isExPageEmpty = exPage.isEmpty;
  616. if (deleteExPageIfEmpty && isExPageEmpty) {
  617. await this.deleteOne({ _id: exPage._id });
  618. logger.warn('Deleted empty page since it was replaced with another page.');
  619. }
  620. return this.findById(newTarget._id);
  621. };
  622. /*
  623. * Find pages by ID and viewer.
  624. */
  625. schema.statics.findByIdsAndViewer = async function (
  626. pageIds: string[],
  627. user,
  628. userGroups?,
  629. includeEmpty?: boolean,
  630. includeAnyoneWithTheLink?: boolean,
  631. ): Promise<PageDocument[]> {
  632. const baseQuery = this.find({ _id: { $in: pageIds } });
  633. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  634. await queryBuilder.addViewerCondition(
  635. user,
  636. userGroups,
  637. includeAnyoneWithTheLink,
  638. );
  639. return queryBuilder.query.exec();
  640. };
  641. /*
  642. * Find a page by path and viewer. Pass true to useFindOne to use findOne method.
  643. */
  644. schema.statics.findByPathAndViewer = async function (
  645. path: string | null,
  646. user,
  647. userGroups = null,
  648. useFindOne = false,
  649. includeEmpty = false,
  650. ): Promise<((PageDocument | PageDocument[]) & HasObjectId) | null> {
  651. if (path == null) {
  652. throw new Error('path is required.');
  653. }
  654. const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
  655. const includeAnyoneWithTheLink = useFindOne;
  656. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  657. await queryBuilder.addViewerCondition(
  658. user,
  659. userGroups,
  660. includeAnyoneWithTheLink,
  661. );
  662. return queryBuilder.query.exec();
  663. };
  664. schema.statics.descendantCountByPaths = async function (
  665. paths: string[],
  666. user: IUserHasId,
  667. userGroups = null,
  668. includeEmpty = false,
  669. includeAnyoneWithTheLink = false,
  670. ): Promise<IPagePathWithDescendantCount[]> {
  671. if (paths.length === 0) {
  672. throw new Error('paths are required');
  673. }
  674. const baseQuery = this.find({ path: { $in: paths } });
  675. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  676. await queryBuilder.addViewerCondition(
  677. user,
  678. userGroups,
  679. includeAnyoneWithTheLink,
  680. );
  681. const conditions = queryBuilder.query._conditions;
  682. const aggregationPipeline = [
  683. {
  684. $match: conditions,
  685. },
  686. {
  687. $project: {
  688. _id: 0,
  689. path: 1,
  690. descendantCount: 1,
  691. },
  692. },
  693. {
  694. $group: {
  695. _id: '$path',
  696. descendantCount: { $first: '$descendantCount' },
  697. },
  698. },
  699. {
  700. $project: {
  701. _id: 0,
  702. path: '$_id',
  703. descendantCount: 1,
  704. },
  705. },
  706. ];
  707. const pages =
  708. await this.aggregate<IPagePathWithDescendantCount>(aggregationPipeline);
  709. return pages;
  710. };
  711. schema.statics.countByPathAndViewer = async function (
  712. path: string | null,
  713. user,
  714. userGroups = null,
  715. includeEmpty = false,
  716. ): Promise<number> {
  717. if (path == null) {
  718. throw new Error('path is required.');
  719. }
  720. const baseQuery = this.count({ path });
  721. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  722. await queryBuilder.addViewerCondition(user, userGroups);
  723. return queryBuilder.query.exec();
  724. };
  725. schema.statics.findRecentUpdatedPages = async function (
  726. path: string,
  727. user,
  728. options: FindRecentUpdatedPagesOption,
  729. includeEmpty = false,
  730. ): Promise<PaginatedPages> {
  731. const sortOpt = {};
  732. sortOpt[options.sort] = options.desc;
  733. const User = mongoose.model('User') as any;
  734. if (path == null) {
  735. throw new Error('path is required.');
  736. }
  737. const baseQuery = this.find({});
  738. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  739. if (!options.includeTrashed) {
  740. queryBuilder.addConditionToExcludeTrashed();
  741. }
  742. if (!options.includeWipPage) {
  743. queryBuilder.addConditionToExcludeWipPage();
  744. }
  745. queryBuilder.addConditionToListWithDescendants(path, options);
  746. queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
  747. await queryBuilder.addViewerCondition(
  748. user,
  749. undefined,
  750. undefined,
  751. !options.hideRestrictedByOwner,
  752. !options.hideRestrictedByGroup,
  753. );
  754. const pages = await this.paginate(queryBuilder.query.clone(), {
  755. lean: true,
  756. sort: sortOpt,
  757. offset: options.offset,
  758. limit: options.limit,
  759. });
  760. const results = {
  761. pages: pages.docs,
  762. totalCount: pages.totalDocs,
  763. offset: options.offset,
  764. limit: options.limit,
  765. };
  766. return results;
  767. };
  768. /*
  769. * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  770. * The result will include the target as well
  771. */
  772. schema.statics.findTargetAndAncestorsByPathOrId = async function (
  773. pathOrId: string,
  774. user,
  775. userGroups,
  776. ): Promise<TargetAndAncestorsResult> {
  777. let path: string;
  778. if (!hasSlash(pathOrId)) {
  779. const _id = pathOrId;
  780. const page = await this.findOne({ _id });
  781. path = page == null ? '/' : page.path;
  782. } else {
  783. path = pathOrId;
  784. }
  785. const ancestorPaths = collectAncestorPaths(path);
  786. ancestorPaths.push(path); // include target
  787. // Do not populate
  788. const queryBuilder = new PageQueryBuilder(this.find(), true);
  789. await queryBuilder.addViewerCondition(user, userGroups);
  790. const _targetAndAncestors: PageDocument[] = await queryBuilder
  791. .addConditionAsOnTree()
  792. .addConditionToListByPathsArray(ancestorPaths)
  793. .addConditionToMinimizeDataForRendering()
  794. .addConditionToSortPagesByDescPath()
  795. .query.lean()
  796. .exec();
  797. // no same path pages
  798. const ancestorsMap = new Map<string, PageDocument>();
  799. _targetAndAncestors.forEach((page) => {
  800. ancestorsMap.set(page.path, page);
  801. });
  802. const targetAndAncestors = Array.from(ancestorsMap.values());
  803. const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
  804. return { targetAndAncestors, rootPage };
  805. };
  806. /**
  807. * Create empty pages at paths at which no pages exist
  808. * @param paths Page paths
  809. * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
  810. */
  811. schema.statics.createEmptyPagesByPaths = async function (
  812. paths: string[],
  813. aggrPipelineForExistingPages: any[],
  814. ): Promise<void> {
  815. const existingPages = await this.aggregate(aggrPipelineForExistingPages);
  816. const existingPagePaths = existingPages.map((page) => page.path);
  817. const notExistingPagePaths = paths.filter(
  818. (path) => !existingPagePaths.includes(path),
  819. );
  820. await this.insertMany(
  821. notExistingPagePaths.map((path) => ({ path, isEmpty: true })),
  822. );
  823. };
  824. /**
  825. * Find a parent page by path
  826. */
  827. schema.statics.findParentByPath = async function (
  828. path: string,
  829. ): Promise<HydratedDocument<PageDocument> | null> {
  830. const parentPath = nodePath.dirname(path);
  831. const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
  832. const pagesCanBeParent = await builder.addConditionAsOnTree().query.exec();
  833. if (pagesCanBeParent.length >= 1) {
  834. return pagesCanBeParent[0]; // the earliest page will be the result
  835. }
  836. return null;
  837. };
  838. /*
  839. * Utils from obsolete-page.js
  840. */
  841. export async function pushRevision(pageData, newRevision, user) {
  842. await newRevision.save();
  843. pageData.revision = newRevision;
  844. pageData.latestRevisionBodyLength = newRevision.body.length;
  845. pageData.lastUpdateUser = user?._id ?? user;
  846. pageData.updatedAt = Date.now();
  847. return pageData.save();
  848. }
  849. /**
  850. * add/subtract descendantCount of pages with provided paths by increment.
  851. * increment can be negative number
  852. */
  853. schema.statics.incrementDescendantCountOfPageIds = async function (
  854. pageIds: ObjectIdLike[],
  855. increment: number,
  856. ): Promise<void> {
  857. await this.updateMany(
  858. { _id: { $in: pageIds } },
  859. { $inc: { descendantCount: increment } },
  860. );
  861. };
  862. /**
  863. * recount descendantCount of a page with the provided id and return it
  864. */
  865. schema.statics.recountDescendantCount = async function (
  866. id: ObjectIdLike,
  867. ): Promise<number> {
  868. const res = await this.aggregate([
  869. {
  870. $match: {
  871. parent: id,
  872. },
  873. },
  874. {
  875. $project: {
  876. parent: 1,
  877. isEmpty: 1,
  878. descendantCount: 1,
  879. },
  880. },
  881. {
  882. $group: {
  883. _id: '$parent',
  884. sumOfDescendantCount: {
  885. $sum: '$descendantCount',
  886. },
  887. sumOfDocsCount: {
  888. $sum: {
  889. // biome-ignore lint/suspicious/noThenProperty: ignore
  890. $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
  891. },
  892. },
  893. },
  894. },
  895. {
  896. $set: {
  897. descendantCount: {
  898. $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
  899. },
  900. },
  901. },
  902. ]);
  903. return res.length === 0 ? 0 : res[0].descendantCount;
  904. };
  905. schema.statics.findAncestorsUsingParentRecursively = async function (
  906. pageId: ObjectIdLike,
  907. shouldIncludeTarget: boolean,
  908. ) {
  909. const self = this;
  910. const target = await this.findById(pageId);
  911. if (target == null) {
  912. throw Error('Target not found');
  913. }
  914. async function findAncestorsRecursively(
  915. target,
  916. ancestors = shouldIncludeTarget ? [target] : [],
  917. ) {
  918. const parent = await self.findOne({ _id: target.parent });
  919. if (parent == null) {
  920. return ancestors;
  921. }
  922. return findAncestorsRecursively(parent, [...ancestors, parent]);
  923. }
  924. return findAncestorsRecursively(target);
  925. };
  926. // TODO: write test code
  927. /**
  928. * Recursively removes empty pages at leaf position.
  929. * @param pageId ObjectIdLike
  930. * @returns Promise<void>
  931. */
  932. schema.statics.removeLeafEmptyPagesRecursively = async function (
  933. pageId: ObjectIdLike,
  934. ): Promise<void> {
  935. const self = this;
  936. const initialPage = await this.findById(pageId);
  937. if (initialPage == null) {
  938. return;
  939. }
  940. if (!initialPage.isEmpty) {
  941. return;
  942. }
  943. async function generatePageIdsToRemove(
  944. childPage,
  945. page,
  946. pageIds: ObjectIdLike[] = [],
  947. ): Promise<ObjectIdLike[]> {
  948. if (!page.isEmpty) {
  949. return pageIds;
  950. }
  951. const isChildrenOtherThanTargetExist = await self.exists({
  952. _id: { $ne: childPage?._id },
  953. parent: page._id,
  954. });
  955. if (isChildrenOtherThanTargetExist) {
  956. return pageIds;
  957. }
  958. pageIds.push(page._id);
  959. const nextPage = await self.findById(page.parent);
  960. if (nextPage == null) {
  961. return pageIds;
  962. }
  963. return generatePageIdsToRemove(page, nextPage, pageIds);
  964. }
  965. const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
  966. await this.deleteMany({ _id: { $in: pageIdsToRemove } });
  967. };
  968. schema.statics.normalizeDescendantCountById = async function (pageId) {
  969. const children = await this.find({ parent: pageId });
  970. const sumChildrenDescendantCount = children
  971. .map((d) => d.descendantCount)
  972. .reduce((c1, c2) => c1 + c2);
  973. const sumChildPages = children.filter((p) => !p.isEmpty).length;
  974. return this.updateOne(
  975. { _id: pageId },
  976. { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } },
  977. { new: true },
  978. );
  979. };
  980. schema.statics.takeOffFromTree = async function (pageId: ObjectIdLike) {
  981. return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
  982. };
  983. schema.statics.removeEmptyPages = async function (
  984. pageIdsToNotRemove: ObjectIdLike[],
  985. paths: string[],
  986. ): Promise<void> {
  987. await this.deleteMany({
  988. _id: {
  989. $nin: pageIdsToNotRemove,
  990. },
  991. path: {
  992. $in: paths,
  993. },
  994. isEmpty: true,
  995. });
  996. };
  997. /**
  998. * Find a not empty parent recursively.
  999. * @param {string} path
  1000. * @returns {Promise<PageDocument | null>}
  1001. */
  1002. schema.statics.findNotEmptyParentByPathRecursively = async function (
  1003. path: string,
  1004. ): Promise<PageDocument | null> {
  1005. const parent = await this.findParentByPath(path);
  1006. if (parent == null) {
  1007. return null;
  1008. }
  1009. const recursive = async (page: PageDocument): Promise<PageDocument> => {
  1010. if (!page.isEmpty) {
  1011. return page;
  1012. }
  1013. const next = await this.findById(page.parent);
  1014. if (next == null || isTopPage(next.path)) {
  1015. return page;
  1016. }
  1017. return recursive(next);
  1018. };
  1019. const notEmptyParent = await recursive(parent);
  1020. return notEmptyParent;
  1021. };
  1022. schema.statics.findParent = async function (
  1023. pageId,
  1024. ): Promise<PageDocument | null> {
  1025. return this.findOne({ _id: pageId });
  1026. };
  1027. schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
  1028. export function generateGrantCondition(
  1029. user,
  1030. userGroups: ObjectIdLike[] | null,
  1031. includeAnyoneWithTheLink = false,
  1032. showPagesRestrictedByOwner = false,
  1033. showPagesRestrictedByGroup = false,
  1034. ): { $or: any[] } {
  1035. const grantConditions: AnyObject[] = [
  1036. { grant: null },
  1037. { grant: GRANT_PUBLIC },
  1038. ];
  1039. if (includeAnyoneWithTheLink) {
  1040. grantConditions.push({ grant: GRANT_RESTRICTED });
  1041. }
  1042. if (showPagesRestrictedByOwner) {
  1043. grantConditions.push({ grant: GRANT_SPECIFIED }, { grant: GRANT_OWNER });
  1044. } else if (user != null) {
  1045. grantConditions.push(
  1046. { grant: GRANT_SPECIFIED, grantedUsers: user._id },
  1047. { grant: GRANT_OWNER, grantedUsers: user._id },
  1048. );
  1049. }
  1050. if (showPagesRestrictedByGroup) {
  1051. grantConditions.push({ grant: GRANT_USER_GROUP });
  1052. } else if (userGroups != null && userGroups.length > 0) {
  1053. grantConditions.push({
  1054. grant: GRANT_USER_GROUP,
  1055. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  1056. });
  1057. }
  1058. return {
  1059. $or: grantConditions,
  1060. };
  1061. }
  1062. schema.statics.generateGrantCondition = generateGrantCondition;
  1063. function generateGrantConditionForSystemDeletion(): { $or: any[] } {
  1064. const grantCondition: AnyObject[] = [
  1065. { grant: null },
  1066. { grant: GRANT_PUBLIC },
  1067. { grant: GRANT_RESTRICTED },
  1068. { grant: GRANT_SPECIFIED },
  1069. { grant: GRANT_OWNER },
  1070. { grant: GRANT_USER_GROUP },
  1071. ];
  1072. return {
  1073. $or: grantCondition,
  1074. };
  1075. }
  1076. schema.statics.generateGrantConditionForSystemDeletion =
  1077. generateGrantConditionForSystemDeletion;
  1078. // find ancestor page with isEmpty: false. If parameter path is '/', return null
  1079. schema.statics.findNonEmptyClosestAncestor = async function (
  1080. path: string,
  1081. ): Promise<PageDocument | null> {
  1082. if (path === '/') {
  1083. return null;
  1084. }
  1085. const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
  1086. const ancestors = await builderForAncestors
  1087. .addConditionToListOnlyAncestors(path) // only ancestor paths
  1088. .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
  1089. .query.exec();
  1090. return ancestors[0] ?? null;
  1091. };
  1092. schema.statics.removeGroupsToDeleteFromPages = async function (
  1093. pages: PageDocument[],
  1094. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  1095. ) {
  1096. const groupsToDeleteIds = groupsToDelete.map((group) => group._id.toString());
  1097. const pageGroups = pages.reduce(
  1098. (
  1099. acc: { canPublicize: PageDocument[]; cannotPublicize: PageDocument[] },
  1100. page,
  1101. ) => {
  1102. const canPublicize = page.grantedGroups.every((group) =>
  1103. groupsToDeleteIds.includes(getIdForRef(group.item).toString()),
  1104. );
  1105. acc[canPublicize ? 'canPublicize' : 'cannotPublicize'].push(page);
  1106. return acc;
  1107. },
  1108. { canPublicize: [], cannotPublicize: [] },
  1109. );
  1110. // Only publicize pages that can only be accessed by the groups to be deleted
  1111. const publicizeQueries = pageGroups.canPublicize.map((page) => {
  1112. return {
  1113. updateOne: {
  1114. filter: { _id: page._id },
  1115. update: {
  1116. grantedGroups: [],
  1117. grant: this.GRANT_PUBLIC,
  1118. },
  1119. },
  1120. };
  1121. });
  1122. // Remove the groups to be deleted from the grantedGroups of the pages that can be accessed by other groups
  1123. const removeFromGrantedGroupsQueries = pageGroups.cannotPublicize.map(
  1124. (page) => {
  1125. return {
  1126. updateOne: {
  1127. filter: { _id: page._id },
  1128. update: {
  1129. $set: {
  1130. grantedGroups: page.grantedGroups.filter(
  1131. (group) =>
  1132. !groupsToDeleteIds.includes(
  1133. getIdForRef(group.item).toString(),
  1134. ),
  1135. ),
  1136. },
  1137. },
  1138. },
  1139. };
  1140. },
  1141. );
  1142. await this.bulkWrite([
  1143. ...publicizeQueries,
  1144. ...removeFromGrantedGroupsQueries,
  1145. ]);
  1146. };
  1147. /*
  1148. * get latest revision body length
  1149. */
  1150. schema.methods.getLatestRevisionBodyLength = async function (
  1151. this: PageDocument,
  1152. ): Promise<number | null | undefined> {
  1153. if (!this.isLatestRevision() || this.revision == null) {
  1154. return null;
  1155. }
  1156. if (this.latestRevisionBodyLength == null) {
  1157. await this.calculateAndUpdateLatestRevisionBodyLength();
  1158. }
  1159. return this.latestRevisionBodyLength;
  1160. };
  1161. /*
  1162. * calculate and update latestRevisionBodyLength
  1163. */
  1164. schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function (
  1165. this: PageDocument,
  1166. ): Promise<void> {
  1167. if (!this.isLatestRevision() || this.revision == null) {
  1168. logger.error('revision field is required.');
  1169. return;
  1170. }
  1171. // eslint-disable-next-line rulesdir/no-populate
  1172. const populatedPageDocument = await this.populate<PageDocument>(
  1173. 'revision',
  1174. 'body',
  1175. );
  1176. assert(populatedPageDocument.revision != null);
  1177. assert(isPopulated(populatedPageDocument.revision));
  1178. this.latestRevisionBodyLength = populatedPageDocument.revision.body.length;
  1179. await this.save();
  1180. };
  1181. schema.methods.publish = function () {
  1182. this.wip = undefined;
  1183. this.ttlTimestamp = undefined;
  1184. };
  1185. schema.methods.unpublish = function () {
  1186. this.wip = true;
  1187. this.ttlTimestamp = undefined;
  1188. };
  1189. schema.methods.makeWip = function (disableTtl: boolean) {
  1190. this.wip = true;
  1191. if (!disableTtl) {
  1192. this.ttlTimestamp = new Date();
  1193. }
  1194. };
  1195. /*
  1196. * Merge obsolete page model methods and define new methods which depend on crowi instance
  1197. */
  1198. export default function PageModel(crowi: Crowi | null): any {
  1199. // add old page schema methods
  1200. const pageSchema = getPageSchema(crowi);
  1201. schema.methods = { ...pageSchema.methods, ...schema.methods };
  1202. schema.statics = { ...pageSchema.statics, ...schema.statics };
  1203. return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
  1204. }