create-page.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import { allOrigin } from '@growi/core';
  2. import type { IPage, IUser, IUserHasId } from '@growi/core/dist/interfaces';
  3. import { SCOPE } from '@growi/core/dist/interfaces';
  4. import { ErrorV3 } from '@growi/core/dist/models';
  5. import {
  6. isCreatablePage,
  7. isUserPage,
  8. isUsersHomepage,
  9. } from '@growi/core/dist/utils/page-path-utils';
  10. import {
  11. attachTitleHeader,
  12. normalizePath,
  13. } from '@growi/core/dist/utils/path-utils';
  14. import type { Request, RequestHandler } from 'express';
  15. import type { ValidationChain } from 'express-validator';
  16. import { body } from 'express-validator';
  17. import type { HydratedDocument } from 'mongoose';
  18. import mongoose from 'mongoose';
  19. import { isAiEnabled } from '~/features/openai/server/services';
  20. import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
  21. import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
  22. import { subscribeRuleNames } from '~/interfaces/in-app-notification';
  23. import type { IOptionsForCreate } from '~/interfaces/page';
  24. import type Crowi from '~/server/crowi';
  25. import { accessTokenParser } from '~/server/middlewares/access-token-parser';
  26. import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
  27. import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
  28. import type { PageDocument, PageModel } from '~/server/models/page';
  29. import PageTagRelation from '~/server/models/page-tag-relation';
  30. import {
  31. serializePageSecurely,
  32. serializeRevisionSecurely,
  33. } from '~/server/models/serializers';
  34. import { configManager } from '~/server/service/config-manager';
  35. import { getTranslation } from '~/server/service/i18next';
  36. import loggerFactory from '~/utils/logger';
  37. import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
  38. import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
  39. import type { ApiV3Response } from '../interfaces/apiv3-response';
  40. const logger = loggerFactory('growi:routes:apiv3:page:create-page');
  41. async function generateUntitledPath(
  42. parentPath: string,
  43. basePathname: string,
  44. index = 1,
  45. ): Promise<string> {
  46. const Page = mongoose.model<IPage>('Page');
  47. const path = normalizePath(
  48. `${normalizePath(parentPath)}/${basePathname}-${index}`,
  49. );
  50. if ((await Page.exists({ path, isEmpty: false })) != null) {
  51. return generateUntitledPath(parentPath, basePathname, index + 1);
  52. }
  53. return path;
  54. }
  55. async function determinePath(
  56. _parentPath?: string,
  57. _path?: string,
  58. optionalParentPath?: string,
  59. ): Promise<string> {
  60. const { t } = await getTranslation();
  61. const basePathname = t?.('create_page.untitled') || 'Untitled';
  62. if (_path != null) {
  63. const path = normalizePath(_path);
  64. // when path is valid
  65. if (isCreatablePage(path)) {
  66. return normalizePath(path);
  67. }
  68. // when optionalParentPath is set
  69. if (optionalParentPath != null) {
  70. return generateUntitledPath(optionalParentPath, basePathname);
  71. }
  72. // when path is invalid
  73. throw new Error('Could not create the page for the path');
  74. }
  75. if (_parentPath != null) {
  76. const parentPath = normalizePath(_parentPath);
  77. // when parentPath is user's homepage
  78. if (isUsersHomepage(parentPath)) {
  79. return generateUntitledPath(parentPath, basePathname);
  80. }
  81. // when parentPath is valid
  82. if (isCreatablePage(parentPath)) {
  83. return generateUntitledPath(parentPath, basePathname);
  84. }
  85. // when optionalParentPath is set
  86. if (optionalParentPath != null) {
  87. return generateUntitledPath(optionalParentPath, basePathname);
  88. }
  89. // when parentPath is invalid
  90. throw new Error('Could not create the page for the parentPath');
  91. }
  92. // when both path and parentPath are not specified
  93. return generateUntitledPath('/', basePathname);
  94. }
  95. type ReqBody = IApiv3PageCreateParams;
  96. interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
  97. user: IUserHasId;
  98. }
  99. type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
  100. export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
  101. const Page = mongoose.model<IPage, PageModel>('Page');
  102. const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>(
  103. 'User',
  104. );
  105. const loginRequiredStrictly = require('../../../middlewares/login-required')(
  106. crowi,
  107. );
  108. // define validators for req.body
  109. const validator: ValidationChain[] = [
  110. body('path')
  111. .optional()
  112. .not()
  113. .isEmpty({ ignore_whitespace: true })
  114. .withMessage("Empty value is not allowed for 'path'"),
  115. body('parentPath')
  116. .optional()
  117. .not()
  118. .isEmpty({ ignore_whitespace: true })
  119. .withMessage("Empty value is not allowed for 'parentPath'"),
  120. body('optionalParentPath')
  121. .optional()
  122. .not()
  123. .isEmpty({ ignore_whitespace: true })
  124. .withMessage("Empty value is not allowed for 'optionalParentPath'"),
  125. body('body')
  126. .optional()
  127. .isString()
  128. .withMessage('body must be string or undefined'),
  129. body('grant')
  130. .optional()
  131. .isInt({ min: 0, max: 5 })
  132. .withMessage('grant must be integer from 1 to 5'),
  133. body('onlyInheritUserRelatedGrantedGroups')
  134. .optional()
  135. .isBoolean()
  136. .withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
  137. body('overwriteScopesOfDescendants')
  138. .optional()
  139. .isBoolean()
  140. .withMessage('overwriteScopesOfDescendants must be boolean'),
  141. body('pageTags').optional().isArray().withMessage('pageTags must be array'),
  142. body('isSlackEnabled')
  143. .optional()
  144. .isBoolean()
  145. .withMessage('isSlackEnabled must be boolean'),
  146. body('slackChannels')
  147. .optional()
  148. .isString()
  149. .withMessage('slackChannels must be string'),
  150. body('wip').optional().isBoolean().withMessage('wip must be boolean'),
  151. body('origin')
  152. .optional()
  153. .isIn(allOrigin)
  154. .withMessage('origin must be "view" or "editor"'),
  155. ];
  156. async function determineBodyAndTags(
  157. path: string,
  158. _body: string | null | undefined,
  159. _tags: string[] | null | undefined,
  160. ): Promise<{ body: string; tags: string[] }> {
  161. let body: string = _body ?? '';
  162. let tags: string[] = _tags ?? [];
  163. if (_body == null) {
  164. const isEnabledAttachTitleHeader = await configManager.getConfig(
  165. 'customize:isEnabledAttachTitleHeader',
  166. );
  167. if (isEnabledAttachTitleHeader) {
  168. body += `${attachTitleHeader(path)}\n`;
  169. }
  170. const templateData = await Page.findTemplate(path);
  171. if (templateData.templateTags != null) {
  172. tags = templateData.templateTags;
  173. }
  174. if (templateData.templateBody != null) {
  175. body += `${templateData.templateBody}\n`;
  176. }
  177. }
  178. return { body, tags };
  179. }
  180. async function saveTags({
  181. createdPage,
  182. pageTags,
  183. }: {
  184. createdPage: PageDocument;
  185. pageTags: string[];
  186. }) {
  187. const tagEvent = crowi.event('tag');
  188. await PageTagRelation.updatePageTags(createdPage.id, pageTags);
  189. tagEvent.emit('update', createdPage, pageTags);
  190. return PageTagRelation.listTagNamesByPage(createdPage.id);
  191. }
  192. async function postAction(
  193. req: CreatePageRequest,
  194. res: ApiV3Response,
  195. createdPage: HydratedDocument<PageDocument>,
  196. ) {
  197. // persist activity
  198. const parameters = {
  199. targetModel: SupportedTargetModel.MODEL_PAGE,
  200. target: createdPage,
  201. action: SupportedAction.ACTION_PAGE_CREATE,
  202. };
  203. const activityEvent = crowi.event('activity');
  204. activityEvent.emit('update', res.locals.activity._id, parameters);
  205. // global notification
  206. try {
  207. await crowi.globalNotificationService.fire(
  208. GlobalNotificationSettingEvent.PAGE_CREATE,
  209. createdPage,
  210. req.user,
  211. );
  212. } catch (err) {
  213. logger.error('Create grobal notification failed', err);
  214. }
  215. // user notification
  216. const { isSlackEnabled, slackChannels } = req.body;
  217. if (isSlackEnabled) {
  218. try {
  219. const results = await crowi.userNotificationService.fire(
  220. createdPage,
  221. req.user,
  222. slackChannels,
  223. 'create',
  224. );
  225. results.forEach((result) => {
  226. if (result.status === 'rejected') {
  227. logger.error('Create user notification failed', result.reason);
  228. }
  229. });
  230. } catch (err) {
  231. logger.error('Create user notification failed', err);
  232. }
  233. }
  234. // create subscription
  235. try {
  236. await crowi.inAppNotificationService.createSubscription(
  237. req.user._id,
  238. createdPage._id,
  239. subscribeRuleNames.PAGE_CREATE,
  240. );
  241. } catch (err) {
  242. logger.error('Failed to create subscription document', err);
  243. }
  244. // Rebuild vector store file
  245. if (isAiEnabled()) {
  246. const { getOpenaiService } = await import(
  247. '~/features/openai/server/services/openai'
  248. );
  249. try {
  250. const openaiService = getOpenaiService();
  251. await openaiService?.createVectorStoreFileOnPageCreate([createdPage]);
  252. } catch (err) {
  253. logger.error('Rebuild vector store failed', err);
  254. }
  255. }
  256. }
  257. const addActivity = generateAddActivityMiddleware();
  258. return [
  259. accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
  260. loginRequiredStrictly,
  261. excludeReadOnlyUser,
  262. addActivity,
  263. validator,
  264. apiV3FormValidator,
  265. async (req: CreatePageRequest, res: ApiV3Response) => {
  266. const { body: bodyByParam, pageTags: tagsByParam } = req.body;
  267. let pathToCreate: string;
  268. try {
  269. const { path, parentPath, optionalParentPath } = req.body;
  270. pathToCreate = await determinePath(
  271. parentPath,
  272. path,
  273. optionalParentPath,
  274. );
  275. } catch (err) {
  276. return res.apiv3Err(
  277. new ErrorV3(err.toString(), 'could_not_create_page'),
  278. );
  279. }
  280. if (isUserPage(pathToCreate)) {
  281. const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
  282. if (!isExistUser) {
  283. return res.apiv3Err(
  284. "Unable to create a page under a non-existent user's user page",
  285. );
  286. }
  287. }
  288. const { body, tags } = await determineBodyAndTags(
  289. pathToCreate,
  290. bodyByParam,
  291. tagsByParam,
  292. );
  293. let createdPage: HydratedDocument<PageDocument>;
  294. try {
  295. const {
  296. grant,
  297. grantUserGroupIds,
  298. onlyInheritUserRelatedGrantedGroups,
  299. overwriteScopesOfDescendants,
  300. wip,
  301. origin,
  302. } = req.body;
  303. const options: IOptionsForCreate = {
  304. onlyInheritUserRelatedGrantedGroups,
  305. overwriteScopesOfDescendants,
  306. wip,
  307. origin,
  308. };
  309. if (grant != null) {
  310. options.grant = grant;
  311. options.grantUserGroupIds = grantUserGroupIds;
  312. }
  313. createdPage = await crowi.pageService.create(
  314. pathToCreate,
  315. body,
  316. req.user,
  317. options,
  318. );
  319. } catch (err) {
  320. logger.error('Error occurred while creating a page.', err);
  321. return res.apiv3Err(err);
  322. }
  323. const savedTags = await saveTags({ createdPage, pageTags: tags });
  324. const result = {
  325. page: serializePageSecurely(createdPage),
  326. tags: savedTags,
  327. revision: serializeRevisionSecurely(createdPage.revision),
  328. };
  329. res.apiv3(result, 201);
  330. postAction(req, res, createdPage);
  331. },
  332. ];
  333. };