refs.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import type { IPage, IUser, IAttachment } from '@growi/core';
  2. import { serializeAttachmentSecurely } from '@growi/core/dist/models/serializers';
  3. import { OptionParser } from '@growi/core/dist/remark-plugins';
  4. import type { Request } from 'express';
  5. import { Router } from 'express';
  6. import type { Model, HydratedDocument } from 'mongoose';
  7. import mongoose, { model, Types } from 'mongoose';
  8. import { FilterXSS } from 'xss';
  9. import loggerFactory from '../../utils/logger';
  10. const logger = loggerFactory('growi:remark-attachment-refs:routes:refs');
  11. function generateRegexp(expression: string): RegExp {
  12. // https://regex101.com/r/uOrwqt/2
  13. const matches = expression.match(/^\/(.+)\/(.*)?$/);
  14. return (matches != null)
  15. ? new RegExp(matches[1], matches[2])
  16. : new RegExp(expression);
  17. }
  18. /**
  19. * add depth condition that limit fetched pages
  20. *
  21. * @param {any} query
  22. * @param {any} pagePath
  23. * @param {any} optionsDepth
  24. * @returns query
  25. */
  26. function addDepthCondition(query, pagePath, optionsDepth) {
  27. // when option strings is 'depth=', the option value is true
  28. if (optionsDepth == null || optionsDepth === true) {
  29. throw new Error('The value of depth option is invalid.');
  30. }
  31. const range = OptionParser.parseRange(optionsDepth);
  32. if (range == null) {
  33. return query;
  34. }
  35. const start = range.start;
  36. const end = range.end;
  37. if (start < 1 || end < 1) {
  38. throw new Error(`specified depth is [${start}:${end}] : start and end are must be larger than 1`);
  39. }
  40. // count slash
  41. const slashNum = pagePath.split('/').length - 1;
  42. const depthStart = slashNum; // start is not affect to fetch page
  43. const depthEnd = slashNum + end - 1;
  44. return query.and({
  45. path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`),
  46. });
  47. }
  48. type RequestWithUser = Request & { user: HydratedDocument<IUser> };
  49. const loginRequiredFallback = (req, res) => {
  50. return res.status(403).send('login required');
  51. };
  52. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  53. export const routesFactory = (crowi): any => {
  54. const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
  55. const accessTokenParser = crowi.require('../middlewares/access-token-parser')(crowi);
  56. const router = Router();
  57. const ObjectId = Types.ObjectId;
  58. const Page = mongoose.model <HydratedDocument<IPage>, Model<any> & any>('Page');
  59. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  60. const { PageQueryBuilder } = Page as any;
  61. /**
  62. * return an Attachment model
  63. */
  64. router.get('/ref', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
  65. const user = req.user;
  66. const { pagePath, fileNameOrId } = req.query;
  67. if (pagePath == null) {
  68. res.status(400).send('the param \'pagePath\' must be set.');
  69. return;
  70. }
  71. const page = await Page.findByPathAndViewer(pagePath, user, undefined, true);
  72. // not found
  73. if (page == null) {
  74. res.status(404).send(`pagePath: '${pagePath}' is not found or forbidden.`);
  75. return;
  76. }
  77. // convert ObjectId
  78. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  79. const orConditions: any[] = [{ originalName: fileNameOrId }];
  80. if (fileNameOrId != null && ObjectId.isValid(fileNameOrId.toString())) {
  81. orConditions.push({ _id: new ObjectId(fileNameOrId.toString()) });
  82. }
  83. const Attachment = model<IAttachment>('Attachment');
  84. const attachment = await Attachment
  85. .findOne({
  86. page: page._id,
  87. $or: orConditions,
  88. })
  89. .populate('creator');
  90. // not found
  91. if (attachment == null) {
  92. res.status(404).send(`attachment '${fileNameOrId}' is not found.`);
  93. return;
  94. }
  95. logger.debug(`attachment '${attachment.id}' is found from fileNameOrId '${fileNameOrId}'`);
  96. // forbidden
  97. const isAccessible = await Page.isAccessiblePageByViewer(attachment.page, user);
  98. if (!isAccessible) {
  99. logger.debug(`attachment '${attachment.id}' is forbidden for user '${user && user.username}'`);
  100. res.status(403).send(`page '${attachment.page}' is forbidden.`);
  101. return;
  102. }
  103. res.status(200).send({ attachment: serializeAttachmentSecurely(attachment) });
  104. });
  105. /**
  106. * return a list of Attachment
  107. */
  108. router.get('/refs', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
  109. const user = req.user;
  110. const { prefix, pagePath } = req.query;
  111. const options: Record<string, string | undefined> = JSON.parse(req.query.options?.toString() ?? '');
  112. // check either 'prefix' or 'pagePath ' is specified
  113. if (prefix == null && pagePath == null) {
  114. res.status(400).send('either the param \'prefix\' or \'pagePath\' must be set.');
  115. return;
  116. }
  117. // check regex
  118. let regex: RegExp | null = null;
  119. const regexOptionValue = options.regexp ?? options.regex;
  120. if (regexOptionValue != null) {
  121. // check the length to avoid ReDoS
  122. if (regexOptionValue.length > 400) {
  123. res.status(400).send('the length of the \'regex\' option is too long.');
  124. return;
  125. }
  126. try {
  127. regex = generateRegexp(regexOptionValue);
  128. }
  129. catch (err) {
  130. res.status(400).send('the \'regex\' option is invalid as RegExp.');
  131. return;
  132. }
  133. }
  134. let builder;
  135. // builder to retrieve descendance
  136. if (prefix != null) {
  137. builder = new PageQueryBuilder(Page.find())
  138. .addConditionToListWithDescendants(prefix)
  139. .addConditionToExcludeTrashed();
  140. }
  141. // builder to get single page
  142. else {
  143. builder = new PageQueryBuilder(Page.find({ path: pagePath }));
  144. }
  145. Page.addConditionToFilteringByViewerForList(builder, user, false);
  146. let pageQuery = builder.query;
  147. // depth
  148. try {
  149. if (prefix != null && options.depth != null) {
  150. pageQuery = addDepthCondition(pageQuery, prefix, options.depth);
  151. }
  152. }
  153. catch (err) {
  154. const filterXSS = new FilterXSS();
  155. return res.status(400).send(filterXSS.process(err.toString()));
  156. }
  157. const results = await pageQuery.select('id').exec();
  158. const pageIds = results.map(result => result.id);
  159. logger.debug('retrieve attachments for pages:', pageIds);
  160. // create query to find
  161. const Attachment = model<IAttachment>('Attachment');
  162. let query = Attachment
  163. .find({
  164. page: { $in: pageIds },
  165. });
  166. // add regex condition
  167. if (regex != null) {
  168. query = query.and([
  169. { originalName: { $regex: regex } },
  170. ]);
  171. }
  172. const attachments = await query
  173. .populate('creator')
  174. .exec();
  175. res.status(200).send({ attachments: attachments.map(attachment => serializeAttachmentSecurely(attachment)) });
  176. });
  177. return router;
  178. };