page-grant.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
  2. import escapeStringRegexp from 'escape-string-regexp';
  3. import mongoose from 'mongoose';
  4. import { IRecordApplicableGrant } from '~/interfaces/page-grant';
  5. import { PageDocument, PageModel } from '~/server/models/page';
  6. import UserGroup from '~/server/models/user-group';
  7. import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
  8. const { addTrailingSlash } = pathUtils;
  9. const { isTopPage } = pagePathUtils;
  10. const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  11. type ObjectIdLike = mongoose.Types.ObjectId | string;
  12. type ComparableTarget = {
  13. grant: number,
  14. grantedUserIds?: ObjectIdLike[],
  15. grantedGroupId?: ObjectIdLike,
  16. applicableUserIds?: ObjectIdLike[],
  17. applicableGroupIds?: ObjectIdLike[],
  18. };
  19. type ComparableAncestor = {
  20. grant: number,
  21. grantedUserIds: ObjectIdLike[],
  22. applicableUserIds?: ObjectIdLike[],
  23. applicableGroupIds?: ObjectIdLike[],
  24. };
  25. type ComparableDescendants = {
  26. isPublicExist: boolean,
  27. grantedUserIds: ObjectIdLike[],
  28. grantedGroupIds: ObjectIdLike[],
  29. };
  30. class PageGrantService {
  31. crowi!: any;
  32. constructor(crowi: any) {
  33. this.crowi = crowi;
  34. }
  35. private validateComparableTarget(comparable: ComparableTarget) {
  36. const Page = mongoose.model('Page') as unknown as PageModel;
  37. const { grant, grantedUserIds, grantedGroupId } = comparable;
  38. if (grant === Page.GRANT_OWNER && (grantedUserIds == null || grantedUserIds.length !== 1)) {
  39. throw Error('grantedUserIds must not be null and must have 1 length');
  40. }
  41. if (grant === Page.GRANT_USER_GROUP && grantedGroupId == null) {
  42. throw Error('grantedGroupId is not specified');
  43. }
  44. }
  45. /**
  46. * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
  47. * @returns boolean
  48. */
  49. private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
  50. this.validateComparableTarget(target);
  51. const Page = mongoose.model('Page') as unknown as PageModel;
  52. /*
  53. * ancestor side
  54. */
  55. // GRANT_PUBLIC
  56. if (ancestor.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
  57. // do nothing
  58. }
  59. // GRANT_OWNER
  60. else if (ancestor.grant === Page.GRANT_OWNER) {
  61. if (target.grantedUserIds?.length !== 1) {
  62. return false;
  63. }
  64. if (target.grant !== Page.GRANT_OWNER) { // only GRANT_OWNER page can exist under GRANT_OWNER page
  65. return false;
  66. }
  67. if (ancestor.grantedUserIds[0].toString() !== target.grantedUserIds[0].toString()) { // the grantedUser must be the same as parent's under the GRANT_OWNER page
  68. return false;
  69. }
  70. }
  71. // GRANT_USER_GROUP
  72. else if (ancestor.grant === Page.GRANT_USER_GROUP) {
  73. if (ancestor.applicableGroupIds == null || ancestor.applicableUserIds == null) {
  74. throw Error('applicableGroupIds and applicableUserIds are not specified');
  75. }
  76. if (target.grant === Page.GRANT_PUBLIC) { // public page must not exist under GRANT_USER_GROUP page
  77. return false;
  78. }
  79. if (target.grant === Page.GRANT_OWNER) {
  80. if (target.grantedUserIds?.length !== 1) {
  81. throw Error('grantedUserIds must have one user');
  82. }
  83. if (!isIncludesObjectId(ancestor.applicableUserIds, target.grantedUserIds[0])) { // GRANT_OWNER pages under GRAND_USER_GROUP page must be owned by the member of the grantedGroup of the GRAND_USER_GROUP page
  84. return false;
  85. }
  86. }
  87. if (target.grant === Page.GRANT_USER_GROUP) {
  88. if (target.grantedGroupId == null) {
  89. throw Error('grantedGroupId must not be null');
  90. }
  91. if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
  92. return false;
  93. }
  94. }
  95. }
  96. if (descendants == null) {
  97. return true;
  98. }
  99. /*
  100. * descendant side
  101. */
  102. // GRANT_PUBLIC
  103. if (target.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
  104. // do nothing
  105. }
  106. // GRANT_OWNER
  107. else if (target.grant === Page.GRANT_OWNER) {
  108. if (target.grantedUserIds?.length !== 1) {
  109. throw Error('grantedUserIds must have one user');
  110. }
  111. if (descendants.isPublicExist) { // public page must not exist under GRANT_OWNER page
  112. return false;
  113. }
  114. if (descendants.grantedGroupIds.length !== 0 || descendants.grantedUserIds.length > 1) { // groups or more than 2 grantedUsers must not be in descendants
  115. return false;
  116. }
  117. if (descendants.grantedUserIds.length === 1 && descendants.grantedUserIds[0].toString() !== target.grantedUserIds[0].toString()) { // if Only me page exists, then all of them must be owned by the same user as the target page
  118. return false;
  119. }
  120. }
  121. // GRANT_USER_GROUP
  122. else if (target.grant === Page.GRANT_USER_GROUP) {
  123. if (target.applicableGroupIds == null || target.applicableUserIds == null) {
  124. throw Error('applicableGroupIds and applicableUserIds must not be null');
  125. }
  126. if (descendants.isPublicExist) { // public page must not exist under GRANT_USER_GROUP page
  127. return false;
  128. }
  129. const shouldNotExistGroupIds = excludeTestIdsFromTargetIds(descendants.grantedGroupIds, target.applicableGroupIds);
  130. const shouldNotExistUserIds = excludeTestIdsFromTargetIds(descendants.grantedUserIds, target.applicableUserIds);
  131. if (shouldNotExistGroupIds.length !== 0 || shouldNotExistUserIds.length !== 0) {
  132. return false;
  133. }
  134. }
  135. return true;
  136. }
  137. /**
  138. * Prepare ComparableTarget
  139. * @returns Promise<ComparableAncestor>
  140. */
  141. private async generateComparableTarget(
  142. grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupId: ObjectIdLike | undefined, includeApplicable: boolean,
  143. ): Promise<ComparableTarget> {
  144. if (includeApplicable) {
  145. const Page = mongoose.model('Page') as unknown as PageModel;
  146. const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
  147. let applicableUserIds: ObjectIdLike[] | undefined;
  148. let applicableGroupIds: ObjectIdLike[] | undefined;
  149. if (grant === Page.GRANT_USER_GROUP) {
  150. const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
  151. if (targetUserGroup == null) {
  152. throw Error('Target user group does not exist');
  153. }
  154. const relatedUsers = await UserGroupRelation.find({ relatedGroup: targetUserGroup._id });
  155. applicableUserIds = relatedUsers.map(u => u.relatedUser);
  156. const applicableGroups = grantedGroupId != null ? await UserGroup.findGroupsWithDescendantsById(grantedGroupId) : null;
  157. applicableGroupIds = applicableGroups?.map(g => g._id) || null;
  158. }
  159. return {
  160. grant,
  161. grantedUserIds,
  162. grantedGroupId,
  163. applicableUserIds,
  164. applicableGroupIds,
  165. };
  166. }
  167. return {
  168. grant,
  169. grantedUserIds,
  170. grantedGroupId,
  171. };
  172. }
  173. /**
  174. * Prepare ComparableAncestor
  175. * @param targetPath string of the target path
  176. * @returns Promise<ComparableAncestor>
  177. */
  178. private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
  179. const Page = mongoose.model('Page') as unknown as PageModel;
  180. const { PageQueryBuilder } = Page;
  181. const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
  182. let applicableUserIds: ObjectIdLike[] | undefined;
  183. let applicableGroupIds: ObjectIdLike[] | undefined;
  184. /*
  185. * make granted users list of ancestor's
  186. */
  187. const builderForAncestors = new PageQueryBuilder(Page.find(), false);
  188. if (!includeNotMigratedPages) {
  189. builderForAncestors.addConditionAsOnTree();
  190. }
  191. const ancestors = await builderForAncestors
  192. .addConditionToListOnlyAncestors(targetPath)
  193. .addConditionToSortPagesByDescPath()
  194. .query
  195. .exec();
  196. const testAncestor = ancestors[0]; // TODO: consider when duplicate testAncestors exist
  197. if (testAncestor == null) {
  198. throw Error('testAncestor must exist');
  199. }
  200. if (testAncestor.grant === Page.GRANT_USER_GROUP) {
  201. // make a set of all users
  202. const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
  203. const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
  204. applicableGroupIds = grantedGroups.map(g => g._id);
  205. applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectIdLike[];
  206. }
  207. return {
  208. grant: testAncestor.grant,
  209. grantedUserIds: testAncestor.grantedUsers,
  210. applicableUserIds,
  211. applicableGroupIds,
  212. };
  213. }
  214. /**
  215. * Prepare ComparableDescendants
  216. * @param targetPath string of the target path
  217. * @returns ComparableDescendants
  218. */
  219. private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
  220. const Page = mongoose.model('Page') as unknown as PageModel;
  221. const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
  222. // Build conditions
  223. const $match: {$or: any} = {
  224. $or: [],
  225. };
  226. const commonCondition = {
  227. path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(targetPath))}`, 'i'),
  228. isEmpty: false,
  229. };
  230. const conditionForNormalizedPages: any = {
  231. ...commonCondition,
  232. parent: { $ne: null },
  233. };
  234. $match.$or.push(conditionForNormalizedPages);
  235. if (includeNotMigratedPages) {
  236. // Add grantCondition for not normalized pages
  237. const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
  238. const grantCondition = Page.generateGrantCondition(user, userGroups);
  239. const conditionForNotNormalizedPages = {
  240. $and: [
  241. {
  242. ...commonCondition,
  243. parent: null,
  244. },
  245. grantCondition,
  246. ],
  247. };
  248. $match.$or.push(conditionForNotNormalizedPages);
  249. }
  250. const result = await Page.aggregate([
  251. { // match to descendants excluding empty pages
  252. $match,
  253. },
  254. {
  255. $project: {
  256. _id: 0,
  257. grant: 1,
  258. grantedUsers: 1,
  259. grantedGroup: 1,
  260. },
  261. },
  262. { // remove duplicates from pipeline
  263. $group: {
  264. _id: '$grant',
  265. grantedGroupSet: { $addToSet: '$grantedGroup' },
  266. grantedUsersSet: { $addToSet: '$grantedUsers' },
  267. },
  268. },
  269. { // flatten granted user set
  270. $unwind: {
  271. path: '$grantedUsersSet',
  272. },
  273. },
  274. ]);
  275. // GRANT_PUBLIC group
  276. const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
  277. // GRANT_OWNER group
  278. const grantOwnerResult = result.filter(r => r._id === Page.GRANT_OWNER)[0]; // users of GRANT_OWNER
  279. const grantedUserIds: ObjectIdLike[] = grantOwnerResult?.grantedUsersSet ?? [];
  280. // GRANT_USER_GROUP group
  281. const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
  282. const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
  283. return {
  284. isPublicExist,
  285. grantedUserIds,
  286. grantedGroupIds,
  287. };
  288. }
  289. /**
  290. * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
  291. * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
  292. * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
  293. * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
  294. * @returns Promise<boolean>
  295. */
  296. async isGrantNormalized(
  297. // eslint-disable-next-line max-len
  298. user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
  299. ): Promise<boolean> {
  300. if (isTopPage(targetPath)) {
  301. return true;
  302. }
  303. const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
  304. if (!shouldCheckDescendants) { // checking the parent is enough
  305. const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
  306. return this.processValidation(comparableTarget, comparableAncestor);
  307. }
  308. const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
  309. const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
  310. return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
  311. }
  312. /**
  313. * Separate normalizable pages and NOT normalizable pages by PageService.prototype.isGrantNormalized method.
  314. * normalizable pages = Pages which are able to run normalizeParentRecursively method (grant & userGroup rule is correct)
  315. * @param pageIds pageIds to be tested
  316. * @returns a tuple with the first element of normalizable pages and the second element of NOT normalizable pages
  317. */
  318. async separateNormalizableAndNotNormalizablePages(user, pages): Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]> {
  319. if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
  320. throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
  321. }
  322. const shouldCheckDescendants = true;
  323. const shouldIncludeNotMigratedPages = true;
  324. const normalizable: (PageDocument & { _id: any })[] = [];
  325. const nonNormalizable: (PageDocument & { _id: any })[] = []; // can be used to tell user which page failed to migrate
  326. for await (const page of pages) {
  327. const {
  328. path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
  329. } = page;
  330. if (!pageUtils.isPageNormalized(page)) {
  331. nonNormalizable.push(page);
  332. continue;
  333. }
  334. if (await this.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
  335. normalizable.push(page);
  336. }
  337. else {
  338. nonNormalizable.push(page);
  339. }
  340. }
  341. return [normalizable, nonNormalizable];
  342. }
  343. async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
  344. const Page = mongoose.model('Page') as unknown as PageModel;
  345. const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
  346. // -- Public only if top page
  347. const isOnlyPublicApplicable = isTopPage(page.path);
  348. if (isOnlyPublicApplicable) {
  349. return {
  350. [Page.GRANT_PUBLIC]: null,
  351. };
  352. }
  353. // Increment an object (type IRecordApplicableGrant)
  354. // grant is never public, anyone with the link, nor specified
  355. const data: IRecordApplicableGrant = {
  356. [Page.GRANT_RESTRICTED]: null, // any page can be restricted
  357. };
  358. // -- Any grant is allowed if parent is null
  359. const isAnyGrantApplicable = page.parent == null;
  360. if (isAnyGrantApplicable) {
  361. data[Page.GRANT_PUBLIC] = null;
  362. data[Page.GRANT_OWNER] = null;
  363. data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
  364. return data;
  365. }
  366. const parent = await Page.findById(page.parent);
  367. if (parent == null) {
  368. throw Error('The page\'s parent does not exist.');
  369. }
  370. const {
  371. grant, grantedUsers, grantedGroup,
  372. } = parent;
  373. if (grant === Page.GRANT_PUBLIC) {
  374. data[Page.GRANT_PUBLIC] = null;
  375. data[Page.GRANT_OWNER] = null;
  376. data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
  377. }
  378. else if (grant === Page.GRANT_OWNER) {
  379. const grantedUser = grantedUsers[0];
  380. const isUserApplicable = grantedUser.toString() === user._id.toString();
  381. if (isUserApplicable) {
  382. data[Page.GRANT_OWNER] = null;
  383. }
  384. }
  385. else if (grant === Page.GRANT_USER_GROUP) {
  386. const group = await UserGroup.findById(grantedGroup);
  387. if (group == null) {
  388. throw Error('Group not found to calculate grant data.');
  389. }
  390. const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
  391. const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
  392. if (isUserExistInGroup) {
  393. data[Page.GRANT_OWNER] = null;
  394. }
  395. data[Page.GRANT_USER_GROUP] = { applicableGroups };
  396. }
  397. return data;
  398. }
  399. }
  400. export default PageGrantService;