bookmarks.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import type { IUserHasId } from '@growi/core';
  2. import { SCOPE } from '@growi/core/dist/interfaces';
  3. import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
  4. import mongoose, { type HydratedDocument } from 'mongoose';
  5. import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
  6. import type { IBookmarkInfo } from '~/interfaces/bookmark-info';
  7. import { accessTokenParser } from '~/server/middlewares/access-token-parser';
  8. import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
  9. import type { BookmarkDocument, BookmarkModel } from '~/server/models/bookmark';
  10. import type { PageDocument, PageModel } from '~/server/models/page';
  11. import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
  12. import { preNotifyService } from '~/server/service/pre-notify';
  13. import loggerFactory from '~/utils/logger';
  14. import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
  15. import BookmarkFolder from '../../models/bookmark-folder';
  16. const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
  17. const express = require('express');
  18. const { body, query, param } = require('express-validator');
  19. const router = express.Router();
  20. /**
  21. * @swagger
  22. *
  23. * components:
  24. * schemas:
  25. * Bookmark:
  26. * description: Bookmark
  27. * type: object
  28. * properties:
  29. * _id:
  30. * type: string
  31. * description: page ID
  32. * example: 5e07345972560e001761fa63
  33. * __v:
  34. * type: number
  35. * description: DB record version
  36. * example: 0
  37. * createdAt:
  38. * type: string
  39. * description: date created at
  40. * example: 2010-01-01T00:00:00.000Z
  41. * page:
  42. * $ref: '#/components/schemas/Page'
  43. * user:
  44. * $ref: '#/components/schemas/ObjectId'
  45. * Bookmarks:
  46. * description: User Root Bookmarks
  47. * type: object
  48. * properties:
  49. * userRootBookmarks:
  50. * type: array
  51. * items:
  52. * $ref: '#/components/schemas/Bookmark'
  53. * BookmarkParams:
  54. * description: BookmarkParams
  55. * type: object
  56. * properties:
  57. * pageId:
  58. * type: string
  59. * description: page ID
  60. * example: 5e07345972560e001761fa63
  61. * bool:
  62. * type: boolean
  63. * description: boolean for bookmark status
  64. *
  65. * BookmarkInfo:
  66. * description: BookmarkInfo
  67. * type: object
  68. * properties:
  69. * sumOfBookmarks:
  70. * type: number
  71. * description: how many people bookmarked the page
  72. * isBookmarked:
  73. * type: boolean
  74. * description: Whether the request user bookmarked (will be returned if the user is included in the request)
  75. * pageId:
  76. * type: string
  77. * description: page ID
  78. * example: 5e07345972560e001761fa63
  79. * bookmarkedUsers:
  80. * type: array
  81. * items:
  82. * $ref: '#/components/schemas/User'
  83. */
  84. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  85. module.exports = (crowi) => {
  86. const loginRequiredStrictly = require('../../middlewares/login-required')(
  87. crowi,
  88. );
  89. const loginRequired = require('../../middlewares/login-required')(
  90. crowi,
  91. true,
  92. );
  93. const addActivity = generateAddActivityMiddleware();
  94. const activityEvent = crowi.event('activity');
  95. const validator = {
  96. bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
  97. bookmarkInfo: [query('pageId').isMongoId()],
  98. userBookmarkList: [
  99. param('userId').isMongoId().withMessage('userId is required'),
  100. ],
  101. };
  102. /**
  103. * @swagger
  104. *
  105. * /bookmarks/info:
  106. * get:
  107. * tags: [Bookmarks]
  108. * summary: /bookmarks/info
  109. * description: Get bookmarked info
  110. * parameters:
  111. * - name: pageId
  112. * in: query
  113. * description: page id
  114. * schema:
  115. * type: string
  116. * responses:
  117. * 200:
  118. * description: Succeeded to get bookmark info.
  119. * content:
  120. * application/json:
  121. * schema:
  122. * $ref: '#/components/schemas/BookmarkInfo'
  123. */
  124. router.get(
  125. '/info',
  126. accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
  127. loginRequired,
  128. validator.bookmarkInfo,
  129. apiV3FormValidator,
  130. async (req, res) => {
  131. const { user } = req;
  132. const { pageId } = req.query;
  133. // Prevent NoSQL injection - ensure pageId is a string
  134. if (typeof pageId !== 'string') {
  135. return res.status(400).apiv3Err('Invalid pageId parameter', 400);
  136. }
  137. const responsesParams: IBookmarkInfo = {
  138. sumOfBookmarks: 0,
  139. isBookmarked: false,
  140. bookmarkedUsers: [],
  141. pageId: '',
  142. };
  143. const Bookmark: BookmarkModel = mongoose.model<
  144. HydratedDocument<BookmarkDocument>,
  145. BookmarkModel
  146. >('Bookmark');
  147. try {
  148. const bookmarks = await Bookmark.find({
  149. page: { $eq: pageId },
  150. }).populate<{
  151. user: IUserHasId;
  152. }>('user');
  153. const users = bookmarks.map((bookmark) =>
  154. serializeUserSecurely(bookmark.user),
  155. );
  156. responsesParams.sumOfBookmarks = bookmarks.length;
  157. responsesParams.bookmarkedUsers = users;
  158. responsesParams.pageId = pageId;
  159. } catch (err) {
  160. logger.error('get-bookmark-document-failed', err);
  161. return res.apiv3Err(err, 500);
  162. }
  163. // guest user only get bookmark count
  164. if (user == null) {
  165. return res.apiv3(responsesParams);
  166. }
  167. try {
  168. const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
  169. responsesParams.isBookmarked = bookmark != null;
  170. return res.apiv3(responsesParams);
  171. } catch (err) {
  172. logger.error('get-bookmark-state-failed', err);
  173. return res.apiv3Err(err, 500);
  174. }
  175. },
  176. );
  177. // select page from bookmark where userid = userid
  178. /**
  179. * @swagger
  180. *
  181. * /bookmarks/{userId}:
  182. * get:
  183. * tags: [Bookmarks]
  184. * summary: /bookmarks/{userId}
  185. * description: Get my bookmarked status
  186. * parameters:
  187. * - name: userId
  188. * in: path
  189. * required: true
  190. * description: user id
  191. * schema:
  192. * type: string
  193. * responses:
  194. * 200:
  195. * description: Succeeded to get my bookmarked status.
  196. * content:
  197. * application/json:
  198. * schema:
  199. * $ref: '#/components/schemas/Bookmarks'
  200. */
  201. router.get(
  202. '/:userId',
  203. accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
  204. loginRequired,
  205. validator.userBookmarkList,
  206. apiV3FormValidator,
  207. async (req, res) => {
  208. const { userId } = req.params;
  209. if (userId == null) {
  210. return res.apiv3Err('User id is not found or forbidden', 400);
  211. }
  212. const Bookmark: BookmarkModel = mongoose.model<
  213. HydratedDocument<BookmarkDocument>,
  214. BookmarkModel
  215. >('Bookmark');
  216. try {
  217. const bookmarkIdsInFolders = await BookmarkFolder.distinct(
  218. 'bookmarks',
  219. { owner: userId },
  220. );
  221. const userRootBookmarks = await Bookmark.find({
  222. _id: { $nin: bookmarkIdsInFolders },
  223. user: userId,
  224. })
  225. .populate({
  226. path: 'page',
  227. model: 'Page',
  228. populate: {
  229. path: 'lastUpdateUser',
  230. model: 'User',
  231. },
  232. })
  233. .exec();
  234. // serialize Bookmark
  235. const serializedUserRootBookmarks = userRootBookmarks.map((bookmark) =>
  236. serializeBookmarkSecurely(bookmark),
  237. );
  238. return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
  239. } catch (err) {
  240. logger.error('get-bookmark-failed', err);
  241. return res.apiv3Err(err, 500);
  242. }
  243. },
  244. );
  245. /**
  246. * @swagger
  247. *
  248. * /bookmarks:
  249. * put:
  250. * tags: [Bookmarks]
  251. * summary: /bookmarks
  252. * description: Update bookmarked status
  253. * requestBody:
  254. * content:
  255. * application/json:
  256. * schema:
  257. * $ref: '#/components/schemas/BookmarkParams'
  258. * responses:
  259. * 200:
  260. * description: Succeeded to update bookmarked status.
  261. * content:
  262. * application/json:
  263. * schema:
  264. * type: object
  265. * properties:
  266. * bookmark:
  267. * $ref: '#/components/schemas/Bookmark'
  268. */
  269. router.put(
  270. '/',
  271. accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
  272. loginRequiredStrictly,
  273. addActivity,
  274. validator.bookmarks,
  275. apiV3FormValidator,
  276. async (req, res) => {
  277. const { pageId, bool } = req.body;
  278. const userId = req.user?._id;
  279. if (userId == null) {
  280. return res.apiv3Err('A logged in user is required.');
  281. }
  282. const Page: PageModel = mongoose.model<
  283. HydratedDocument<PageDocument>,
  284. PageModel
  285. >('Page');
  286. const Bookmark: BookmarkModel = mongoose.model<
  287. HydratedDocument<BookmarkDocument>,
  288. BookmarkModel
  289. >('Bookmark');
  290. let page: HydratedDocument<PageDocument> | null;
  291. let bookmark: HydratedDocument<BookmarkDocument> | null;
  292. try {
  293. page = await Page.findByIdAndViewer(pageId, req.user, undefined, true);
  294. if (page == null) {
  295. return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
  296. }
  297. bookmark = await Bookmark.findByPageIdAndUserId(page._id, req.user._id);
  298. if (bookmark == null) {
  299. if (bool) {
  300. bookmark = await Bookmark.add(page._id, req.user);
  301. } else {
  302. logger.warn(
  303. `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
  304. );
  305. }
  306. } else {
  307. // eslint-disable-next-line no-lonely-if
  308. if (bool) {
  309. logger.warn(
  310. `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
  311. );
  312. } else {
  313. bookmark = await Bookmark.removeBookmark(page._id, req.user);
  314. }
  315. }
  316. } catch (err) {
  317. logger.error('update-bookmark-failed', err);
  318. return res.apiv3Err(err, 500);
  319. }
  320. if (bookmark != null) {
  321. bookmark.depopulate('page');
  322. bookmark.depopulate('user');
  323. }
  324. const parameters = {
  325. targetModel: SupportedTargetModel.MODEL_PAGE,
  326. target: page,
  327. action: bool
  328. ? SupportedAction.ACTION_PAGE_BOOKMARK
  329. : SupportedAction.ACTION_PAGE_UNBOOKMARK,
  330. };
  331. activityEvent.emit(
  332. 'update',
  333. res.locals.activity._id,
  334. parameters,
  335. page,
  336. preNotifyService.generatePreNotify,
  337. );
  338. return res.apiv3({ bookmark });
  339. },
  340. );
  341. return router;
  342. };