pages.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983
  1. import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
  2. import { subscribeRuleNames } from '~/interfaces/in-app-notification';
  3. import { PageGrant } from '~/interfaces/page';
  4. import loggerFactory from '~/utils/logger';
  5. import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
  6. import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
  7. import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
  8. import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
  9. import { ErrorV3 } from '@growi/core';
  10. const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
  11. const { pathUtils, pagePathUtils } = require('@growi/core');
  12. const express = require('express');
  13. const { body } = require('express-validator');
  14. const { query } = require('express-validator');
  15. const mongoose = require('mongoose');
  16. const { isCreatablePage } = pagePathUtils;
  17. const router = express.Router();
  18. const LIMIT_FOR_LIST = 10;
  19. const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  20. /**
  21. * @swagger
  22. * tags:
  23. * name: Pages
  24. */
  25. /**
  26. * @swagger
  27. *
  28. * components:
  29. * schemas:
  30. * Tags:
  31. * description: Tags
  32. * type: array
  33. * items:
  34. * $ref: '#/components/schemas/Tag/properties/name'
  35. * example: ['daily', 'report', 'tips']
  36. *
  37. * Tag:
  38. * description: Tag
  39. * type: object
  40. * properties:
  41. * _id:
  42. * type: string
  43. * description: tag ID
  44. * example: 5e2d6aede35da4004ef7e0b7
  45. * name:
  46. * type: string
  47. * description: tag name
  48. * example: daily
  49. * count:
  50. * type: number
  51. * description: Count of tagged pages
  52. * example: 3
  53. */
  54. /**
  55. * @swagger
  56. *
  57. * components:
  58. * schemas:
  59. * Page:
  60. * description: Page
  61. * type: object
  62. * properties:
  63. * _id:
  64. * type: string
  65. * description: page ID
  66. * example: 5e07345972560e001761fa63
  67. * __v:
  68. * type: number
  69. * description: DB record version
  70. * example: 0
  71. * commentCount:
  72. * type: number
  73. * description: count of comments
  74. * example: 3
  75. * createdAt:
  76. * type: string
  77. * description: date created at
  78. * example: 2010-01-01T00:00:00.000Z
  79. * creator:
  80. * $ref: '#/components/schemas/User'
  81. * extended:
  82. * type: object
  83. * description: extend data
  84. * example: {}
  85. * grant:
  86. * type: number
  87. * description: grant
  88. * example: 1
  89. * grantedUsers:
  90. * type: array
  91. * description: granted users
  92. * items:
  93. * type: string
  94. * description: user ID
  95. * example: ["5ae5fccfc5577b0004dbd8ab"]
  96. * lastUpdateUser:
  97. * $ref: '#/components/schemas/User'
  98. * liker:
  99. * type: array
  100. * description: granted users
  101. * items:
  102. * type: string
  103. * description: user ID
  104. * example: []
  105. * path:
  106. * type: string
  107. * description: page path
  108. * example: /Sandbox/Math
  109. * revision:
  110. * type: string
  111. * description: revision ID
  112. * example: ["5ae5fccfc5577b0004dbd8ab"]
  113. * seenUsers:
  114. * type: array
  115. * description: granted users
  116. * items:
  117. * type: string
  118. * description: user ID
  119. * example: ["5ae5fccfc5577b0004dbd8ab"]
  120. * status:
  121. * type: string
  122. * description: status
  123. * enum:
  124. * - 'wip'
  125. * - 'published'
  126. * - 'deleted'
  127. * - 'deprecated'
  128. * example: published
  129. * updatedAt:
  130. * type: string
  131. * description: date updated at
  132. * example: 2010-01-01T00:00:00.000Z
  133. */
  134. module.exports = (crowi) => {
  135. const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
  136. const loginRequired = require('../../middlewares/login-required')(crowi, true);
  137. const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
  138. const adminRequired = require('../../middlewares/admin-required')(crowi);
  139. const Page = crowi.model('Page');
  140. const User = crowi.model('User');
  141. const PageTagRelation = crowi.model('PageTagRelation');
  142. const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
  143. const activityEvent = crowi.event('activity');
  144. const globalNotificationService = crowi.getGlobalNotificationService();
  145. const userNotificationService = crowi.getUserNotificationService();
  146. const { serializePageSecurely } = require('../../models/serializers/page-serializer');
  147. const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
  148. const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
  149. const addActivity = generateAddActivityMiddleware(crowi);
  150. const validator = {
  151. createPage: [
  152. body('body').optional().isString()
  153. .withMessage('body must be string or undefined'),
  154. body('path').exists().not().isEmpty({ ignore_whitespace: true })
  155. .withMessage('path is required'),
  156. body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
  157. body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
  158. body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
  159. body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
  160. body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
  161. ],
  162. renamePage: [
  163. body('pageId').isMongoId().withMessage('pageId is required'),
  164. body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
  165. body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
  166. body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
  167. body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
  168. body('updateMetadata').if(value => value != null).isBoolean().withMessage('updateMetadata must be boolean'),
  169. body('isMoveMode').if(value => value != null).isBoolean().withMessage('isMoveMode must be boolean'),
  170. ],
  171. resumeRenamePage: [
  172. body('pageId').isMongoId().withMessage('pageId is required'),
  173. ],
  174. duplicatePage: [
  175. body('pageId').isMongoId().withMessage('pageId is required'),
  176. body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
  177. body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
  178. ],
  179. deletePages: [
  180. body('pageIdToRevisionIdMap')
  181. .exists()
  182. .withMessage('The body property "pageIdToRevisionIdMap" must be an json map with pageId as key and revisionId as value.'),
  183. body('isCompletely')
  184. .custom(v => v === 'true' || v === true || v == null)
  185. .withMessage('The body property "isCompletely" must be "true" or true. (Omit param for false)'),
  186. body('isRecursively')
  187. .custom(v => v === 'true' || v === true || v == null)
  188. .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
  189. body('isAnyoneWithTheLink')
  190. .custom(v => v === 'true' || v === true || v == null)
  191. .withMessage('The body property "isAnyoneWithTheLink" must be "true" or true. (Omit param for false)'),
  192. ],
  193. legacyPagesMigration: [
  194. body('convertPath').optional().isString().withMessage('convertPath must be a string'),
  195. body('pageIds').optional().isArray().withMessage('pageIds must be an array'),
  196. body('isRecursively')
  197. .optional()
  198. .custom(v => v === 'true' || v === true || v == null)
  199. .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
  200. ],
  201. convertPagesByPath: [
  202. body('convertPath').optional().isString().withMessage('convertPath must be a string'),
  203. ],
  204. };
  205. async function createPageAction({
  206. path, body, user, options,
  207. }) {
  208. const createdPage = await crowi.pageService.create(path, body, user, options);
  209. return createdPage;
  210. }
  211. async function saveTagsAction({ createdPage, pageTags }) {
  212. if (pageTags != null) {
  213. const tagEvent = crowi.event('tag');
  214. await PageTagRelation.updatePageTags(createdPage.id, pageTags);
  215. tagEvent.emit('update', createdPage, pageTags);
  216. return PageTagRelation.listTagNamesByPage(createdPage.id);
  217. }
  218. return [];
  219. }
  220. /**
  221. * @swagger
  222. *
  223. * /pages:
  224. * post:
  225. * tags: [Pages]
  226. * operationId: createPage
  227. * description: Create page
  228. * requestBody:
  229. * content:
  230. * application/json:
  231. * schema:
  232. * properties:
  233. * body:
  234. * type: string
  235. * description: Text of page
  236. * path:
  237. * $ref: '#/components/schemas/Page/properties/path'
  238. * grant:
  239. * $ref: '#/components/schemas/Page/properties/grant'
  240. * grantUserGroupId:
  241. * type: string
  242. * description: UserGroup ID
  243. * example: 5ae5fccfc5577b0004dbd8ab
  244. * pageTags:
  245. * type: array
  246. * items:
  247. * $ref: '#/components/schemas/Tag'
  248. * createFromPageTree:
  249. * type: boolean
  250. * description: Whether the page was created from the page tree or not
  251. * required:
  252. * - body
  253. * - path
  254. * responses:
  255. * 201:
  256. * description: Succeeded to create page.
  257. * content:
  258. * application/json:
  259. * schema:
  260. * properties:
  261. * data:
  262. * type: object
  263. * properties:
  264. * page:
  265. * $ref: '#/components/schemas/Page'
  266. * tags:
  267. * type: array
  268. * items:
  269. * $ref: '#/components/schemas/Tags'
  270. * revision:
  271. * $ref: '#/components/schemas/Revision'
  272. * 409:
  273. * description: page path is already existed
  274. */
  275. router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
  276. const {
  277. body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
  278. } = req.body;
  279. let { path } = req.body;
  280. // check whether path starts slash
  281. path = pathUtils.addHeadingSlash(path);
  282. const options = { overwriteScopesOfDescendants };
  283. if (grant != null) {
  284. options.grant = grant;
  285. options.grantUserGroupId = grantUserGroupId;
  286. }
  287. const isNoBodyPage = body === undefined;
  288. let initialTags = [];
  289. let initialBody = '';
  290. if (isNoBodyPage) {
  291. const isEnabledAttachTitleHeader = await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
  292. if (isEnabledAttachTitleHeader) {
  293. initialBody += `${pathUtils.attachTitleHeader(path)}\n`;
  294. }
  295. const templateData = await Page.findTemplate(path);
  296. if (templateData?.templateTags != null) {
  297. initialTags = templateData.templateTags;
  298. }
  299. if (templateData?.templateBody != null) {
  300. initialBody += `${templateData.templateBody}\n`;
  301. }
  302. }
  303. let createdPage;
  304. try {
  305. createdPage = await createPageAction({
  306. path, body: isNoBodyPage ? initialBody : body, user: req.user, options,
  307. });
  308. }
  309. catch (err) {
  310. logger.error('Error occurred while creating a page.', err);
  311. return res.apiv3Err(err);
  312. }
  313. const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : pageTags });
  314. const result = {
  315. page: serializePageSecurely(createdPage),
  316. tags: savedTags,
  317. revision: serializeRevisionSecurely(createdPage.revision),
  318. };
  319. const parameters = {
  320. targetModel: SupportedTargetModel.MODEL_PAGE,
  321. target: createdPage,
  322. action: SupportedAction.ACTION_PAGE_CREATE,
  323. };
  324. activityEvent.emit('update', res.locals.activity._id, parameters);
  325. res.apiv3(result, 201);
  326. try {
  327. // global notification
  328. await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
  329. }
  330. catch (err) {
  331. logger.error('Create grobal notification failed', err);
  332. }
  333. // user notification
  334. if (isSlackEnabled) {
  335. try {
  336. const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
  337. results.forEach((result) => {
  338. if (result.status === 'rejected') {
  339. logger.error('Create user notification failed', result.reason);
  340. }
  341. });
  342. }
  343. catch (err) {
  344. logger.error('Create user notification failed', err);
  345. }
  346. }
  347. // create subscription
  348. try {
  349. await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
  350. }
  351. catch (err) {
  352. logger.error('Failed to create subscription document', err);
  353. }
  354. });
  355. /**
  356. * @swagger
  357. *
  358. * /pages/recent:
  359. * get:
  360. * tags: [Pages]
  361. * description: Get recently updated pages
  362. * responses:
  363. * 200:
  364. * description: Return pages recently updated
  365. *
  366. */
  367. router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
  368. const limit = parseInt(req.query.limit) || 20;
  369. const offset = parseInt(req.query.offset) || 0;
  370. const queryOptions = {
  371. offset,
  372. limit,
  373. includeTrashed: false,
  374. isRegExpEscapedFromPath: true,
  375. sort: 'updatedAt',
  376. desc: -1,
  377. };
  378. try {
  379. const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
  380. if (result.pages.length > limit) {
  381. result.pages.pop();
  382. }
  383. result.pages.forEach((page) => {
  384. if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
  385. page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
  386. }
  387. });
  388. const PageTagRelation = mongoose.model('PageTagRelation');
  389. const ids = result.pages.map((page) => { return page._id });
  390. const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
  391. // { pageId: [{ tag }, ...] }
  392. const relationsMap = new Map();
  393. // increment relationsMap
  394. relations.forEach((relation) => {
  395. const pageId = relation.relatedPage.toString();
  396. if (!relationsMap.has(pageId)) {
  397. relationsMap.set(pageId, []);
  398. }
  399. if (relation.relatedTag != null) {
  400. relationsMap.get(pageId).push(relation.relatedTag);
  401. }
  402. });
  403. // add tags to each page
  404. result.pages.forEach((page) => {
  405. const pageId = page._id.toString();
  406. page.tags = relationsMap.has(pageId) ? relationsMap.get(pageId) : [];
  407. });
  408. return res.apiv3(result);
  409. }
  410. catch (err) {
  411. logger.error('Failed to get recent pages', err);
  412. return res.apiv3Err(new ErrorV3('Failed to get recent pages', 'unknown'), 500);
  413. }
  414. });
  415. /**
  416. * @swagger
  417. *
  418. *
  419. * /pages/rename:
  420. * post:
  421. * tags: [Pages]
  422. * operationId: renamePage
  423. * description: Rename page
  424. * requestBody:
  425. * content:
  426. * application/json:
  427. * schema:
  428. * properties:
  429. * pageId:
  430. * $ref: '#/components/schemas/Page/properties/_id'
  431. * path:
  432. * $ref: '#/components/schemas/Page/properties/path'
  433. * revisionId:
  434. * type: string
  435. * description: revision ID
  436. * example: 5e07345972560e001761fa63
  437. * newPagePath:
  438. * type: string
  439. * description: new path
  440. * example: /user/alice/new_test
  441. * isRenameRedirect:
  442. * type: boolean
  443. * description: whether redirect page
  444. * updateMetadata:
  445. * type: boolean
  446. * description: whether update meta data
  447. * isRecursively:
  448. * type: boolean
  449. * description: whether rename page with descendants
  450. * required:
  451. * - pageId
  452. * - revisionId
  453. * responses:
  454. * 200:
  455. * description: Succeeded to rename page.
  456. * content:
  457. * application/json:
  458. * schema:
  459. * properties:
  460. * page:
  461. * $ref: '#/components/schemas/Page'
  462. * 401:
  463. * description: page id is invalid
  464. * 409:
  465. * description: page path is already existed
  466. */
  467. router.put('/rename', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
  468. const { pageId, revisionId } = req.body;
  469. let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
  470. const options = {
  471. isRecursively: req.body.isRecursively,
  472. createRedirectPage: req.body.isRenameRedirect,
  473. updateMetadata: req.body.updateMetadata,
  474. isMoveMode: req.body.isMoveMode,
  475. };
  476. const activityParameters = {
  477. ip: req.ip,
  478. endpoint: req.originalUrl,
  479. };
  480. if (!isCreatablePage(newPagePath)) {
  481. return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
  482. }
  483. // check whether path starts slash
  484. newPagePath = pathUtils.addHeadingSlash(newPagePath);
  485. const isExist = await Page.count({ path: newPagePath }) > 0;
  486. if (isExist) {
  487. // if page found, cannot rename to that path
  488. return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
  489. }
  490. let page;
  491. let renamedPage;
  492. try {
  493. page = await Page.findByIdAndViewer(pageId, req.user, null, true);
  494. options.isRecursively = page.descendantCount > 0;
  495. if (page == null) {
  496. return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
  497. }
  498. // empty page does not require revisionId validation
  499. if (!page.isEmpty && revisionId == null) {
  500. return res.apiv3Err(new ErrorV3('revisionId must be a mongoId', 'invalid_body'), 400);
  501. }
  502. if (!page.isEmpty && !page.isUpdatable(revisionId)) {
  503. return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
  504. }
  505. renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
  506. // Respond before sending notification
  507. const result = { page: serializePageSecurely(renamedPage ?? page) };
  508. res.apiv3(result);
  509. }
  510. catch (err) {
  511. logger.error(err);
  512. return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
  513. }
  514. try {
  515. // global notification
  516. await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
  517. oldPath: page.path,
  518. });
  519. }
  520. catch (err) {
  521. logger.error('Move notification failed', err);
  522. }
  523. });
  524. router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
  525. async(req, res) => {
  526. const { pageId } = req.body;
  527. const { user } = req;
  528. // The user has permission to resume rename operation if page is returned.
  529. const page = await Page.findByIdAndViewer(pageId, user, null, true);
  530. if (page == null) {
  531. const msg = 'The operation is forbidden for this user';
  532. const code = 'forbidden-user';
  533. return res.apiv3Err(new ErrorV3(msg, code), 403);
  534. }
  535. const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
  536. if (pageOp == null) {
  537. const msg = 'PageOperation document for Rename Sub operation not found.';
  538. const code = 'document_not_found';
  539. return res.apiv3Err(new ErrorV3(msg, code), 404);
  540. }
  541. try {
  542. await crowi.pageService.resumeRenameSubOperation(page, pageOp);
  543. }
  544. catch (err) {
  545. logger.error(err);
  546. return res.apiv3Err(err, 500);
  547. }
  548. return res.apiv3();
  549. });
  550. /**
  551. * @swagger
  552. *
  553. * /pages/empty-trash:
  554. * delete:
  555. * tags: [Pages]
  556. * description: empty trash
  557. * responses:
  558. * 200:
  559. * description: Succeeded to remove all trash pages
  560. */
  561. router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
  562. const options = {};
  563. const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
  564. const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
  565. if (deletablePages.length === 0) {
  566. const msg = 'No pages can be deleted.';
  567. return res.apiv3Err(new ErrorV3(msg), 500);
  568. }
  569. const parameters = { action: SupportedAction.ACTION_PAGE_EMPTY_TRASH };
  570. // when some pages are not deletable
  571. if (deletablePages.length < pagesInTrash.length) {
  572. try {
  573. const options = { isCompletely: true, isRecursively: true };
  574. await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
  575. activityEvent.emit('update', res.locals.activity._id, parameters);
  576. return res.apiv3({ deletablePages });
  577. }
  578. catch (err) {
  579. logger.error(err);
  580. return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
  581. }
  582. }
  583. // when all pages are deletable
  584. else {
  585. try {
  586. const activityParameters = {
  587. ip: req.ip,
  588. endpoint: req.originalUrl,
  589. };
  590. const pages = await crowi.pageService.emptyTrashPage(req.user, options, activityParameters);
  591. activityEvent.emit('update', res.locals.activity._id, parameters);
  592. return res.apiv3({ pages });
  593. }
  594. catch (err) {
  595. logger.error(err);
  596. return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
  597. }
  598. }
  599. });
  600. validator.displayList = [
  601. query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
  602. ];
  603. router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
  604. const { isTrashPage } = pagePathUtils;
  605. const { path } = req.query;
  606. const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
  607. const page = req.query.page || 1;
  608. const offset = (page - 1) * limit;
  609. let includeTrashed = false;
  610. if (isTrashPage(path)) {
  611. includeTrashed = true;
  612. }
  613. const queryOptions = {
  614. offset,
  615. limit,
  616. includeTrashed,
  617. };
  618. try {
  619. const result = await Page.findListWithDescendants(path, req.user, queryOptions);
  620. result.pages.forEach((page) => {
  621. if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
  622. page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
  623. }
  624. });
  625. return res.apiv3(result);
  626. }
  627. catch (err) {
  628. logger.error('Failed to get Descendants Pages', err);
  629. return res.apiv3Err(err, 500);
  630. }
  631. });
  632. /**
  633. * @swagger
  634. *
  635. *
  636. * /pages/duplicate:
  637. * post:
  638. * tags: [Pages]
  639. * operationId: duplicatePage
  640. * description: Duplicate page
  641. * requestBody:
  642. * content:
  643. * application/json:
  644. * schema:
  645. * properties:
  646. * pageId:
  647. * $ref: '#/components/schemas/Page/properties/_id'
  648. * pageNameInput:
  649. * $ref: '#/components/schemas/Page/properties/path'
  650. * isRecursively:
  651. * type: boolean
  652. * description: whether duplicate page with descendants
  653. * required:
  654. * - pageId
  655. * responses:
  656. * 200:
  657. * description: Succeeded to duplicate page.
  658. * content:
  659. * application/json:
  660. * schema:
  661. * properties:
  662. * page:
  663. * $ref: '#/components/schemas/Page'
  664. *
  665. * 403:
  666. * description: Forbidden to duplicate page.
  667. * 500:
  668. * description: Internal server error.
  669. */
  670. router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
  671. async(req, res) => {
  672. const { pageId, isRecursively } = req.body;
  673. const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
  674. const isCreatable = isCreatablePage(newPagePath);
  675. if (!isCreatable) {
  676. return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
  677. }
  678. // check page existence
  679. const isExist = (await Page.count({ path: newPagePath })) > 0;
  680. if (isExist) {
  681. return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
  682. }
  683. const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
  684. const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
  685. if (page == null || isEmptyAndNotRecursively) {
  686. res.code = 'Page is not found';
  687. logger.error('Failed to find the pages');
  688. return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
  689. }
  690. const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
  691. const result = { page: serializePageSecurely(newParentPage) };
  692. // copy the page since it's used and updated in crowi.pageService.duplicate
  693. const copyPage = { ...page };
  694. copyPage.path = newPagePath;
  695. try {
  696. await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
  697. }
  698. catch (err) {
  699. logger.error('Create grobal notification failed', err);
  700. }
  701. // create subscription (parent page only)
  702. try {
  703. await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
  704. }
  705. catch (err) {
  706. logger.error('Failed to create subscription document', err);
  707. }
  708. const parameters = {
  709. targetModel: SupportedTargetModel.MODEL_PAGE,
  710. target: page,
  711. action: SupportedAction.ACTION_PAGE_DUPLICATE,
  712. };
  713. activityEvent.emit('update', res.locals.activity._id, parameters, page);
  714. return res.apiv3(result);
  715. });
  716. /**
  717. * @swagger
  718. *
  719. *
  720. * /pages/subordinated-list:
  721. * get:
  722. * tags: [Pages]
  723. * operationId: subordinatedList
  724. * description: Get subordinated pages
  725. * parameters:
  726. * - name: path
  727. * in: query
  728. * description: Parent path of search
  729. * schema:
  730. * type: string
  731. * - name: limit
  732. * in: query
  733. * description: Limit of acquisitions
  734. * schema:
  735. * type: number
  736. * responses:
  737. * 200:
  738. * description: Succeeded to retrieve pages.
  739. * content:
  740. * application/json:
  741. * schema:
  742. * properties:
  743. * subordinatedPaths:
  744. * type: object
  745. * description: descendants page
  746. * 500:
  747. * description: Internal server error.
  748. */
  749. router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
  750. const { path } = req.query;
  751. const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
  752. try {
  753. const pageData = await Page.findByPath(path, true);
  754. const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
  755. return res.apiv3({ subordinatedPages: result });
  756. }
  757. catch (err) {
  758. return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
  759. }
  760. });
  761. router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
  762. const {
  763. pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
  764. } = req.body;
  765. // TODO: Commonize the page deletion logic
  766. // see: https://redmine.weseek.co.jp/issues/123550
  767. const pageIds = Object.keys(pageIdToRevisionIdMap);
  768. if (pageIds.length === 0) {
  769. return res.apiv3Err(new ErrorV3('Select pages to delete.', 'no_page_selected'), 400);
  770. }
  771. if (isAnyoneWithTheLink && pageIds.length !== 1) {
  772. return res.apiv3Err(new ErrorV3('Only one restricted page can be selected', 'not_single_page'), 400);
  773. }
  774. if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
  775. return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
  776. }
  777. let pagesToDelete;
  778. try {
  779. pagesToDelete = await Page.findByIdsAndViewer(pageIds, req.user, null, true, isAnyoneWithTheLink);
  780. }
  781. catch (err) {
  782. logger.error('Failed to find pages to delete.', err);
  783. return res.apiv3Err(new ErrorV3('Failed to find pages to delete.'));
  784. }
  785. if (isAnyoneWithTheLink && pagesToDelete[0].grant !== PageGrant.GRANT_RESTRICTED) {
  786. return res.apiv3Err(new ErrorV3('The grant of the retrieved page is not restricted'), 500);
  787. }
  788. let pagesCanBeDeleted;
  789. /*
  790. * Delete Completely
  791. */
  792. if (isCompletely) {
  793. pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
  794. }
  795. /*
  796. * Trash
  797. */
  798. else {
  799. pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
  800. pagesCanBeDeleted = crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
  801. }
  802. if (pagesCanBeDeleted.length === 0) {
  803. const msg = 'No pages can be deleted.';
  804. return res.apiv3Err(new ErrorV3(msg), 500);
  805. }
  806. // run delete
  807. const activityParameters = {
  808. ip: req.ip,
  809. endpoint: req.originalUrl,
  810. };
  811. const options = { isCompletely, isRecursively };
  812. crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options, activityParameters);
  813. return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
  814. });
  815. // eslint-disable-next-line max-len
  816. router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
  817. const { convertPath } = req.body;
  818. // Convert by path
  819. const normalizedPath = pathUtils.normalizePath(convertPath);
  820. try {
  821. await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
  822. }
  823. catch (err) {
  824. logger.error(err);
  825. if (isV5ConversionError(err)) {
  826. return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
  827. }
  828. return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
  829. }
  830. return res.apiv3({});
  831. });
  832. // eslint-disable-next-line max-len
  833. router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
  834. const { pageIds: _pageIds, isRecursively } = req.body;
  835. // Convert by pageIds
  836. const pageIds = _pageIds == null ? [] : _pageIds;
  837. if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
  838. return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
  839. }
  840. if (pageIds.length === 0) {
  841. return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
  842. }
  843. try {
  844. if (isRecursively) {
  845. await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
  846. }
  847. else {
  848. await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
  849. }
  850. }
  851. catch (err) {
  852. return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
  853. }
  854. return res.apiv3({});
  855. });
  856. router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
  857. try {
  858. const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
  859. const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
  860. return res.apiv3({ isV5Compatible, migratablePagesCount });
  861. }
  862. catch (err) {
  863. return res.apiv3Err(new ErrorV3('Failed to obtain migration status'));
  864. }
  865. });
  866. return router;
  867. };