attachment.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import { ErrorV3 } from '@growi/core/dist/models';
  2. import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
  3. import express from 'express';
  4. import multer from 'multer';
  5. import autoReap from 'multer-autoreap';
  6. import { SupportedAction } from '~/interfaces/activity';
  7. import { AttachmentType } from '~/server/interfaces/attachment';
  8. import { accessTokenParser } from '~/server/middlewares/access-token-parser';
  9. import { Attachment } from '~/server/models/attachment';
  10. import { serializePageSecurely, serializeRevisionSecurely } from '~/server/models/serializers';
  11. import loggerFactory from '~/utils/logger';
  12. import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
  13. import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
  14. import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
  15. import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
  16. const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
  17. const router = express.Router();
  18. const {
  19. query, param, body,
  20. } = require('express-validator');
  21. /**
  22. * @swagger
  23. *
  24. * components:
  25. * schemas:
  26. * AttachmentPaginateResult:
  27. * description: AttachmentPaginateResult
  28. * type: object
  29. * properties:
  30. * docs:
  31. * type: array
  32. * items:
  33. * $ref: '#/components/schemas/Attachment'
  34. * totalDocs:
  35. * type: number
  36. * example: 1
  37. * limit:
  38. * type: number
  39. * example: 20
  40. * totalPages:
  41. * type: number
  42. * example: 1
  43. * page:
  44. * type: number
  45. * example: 1
  46. * offset:
  47. * type: number
  48. * example: 0
  49. * prevPage:
  50. * type: number
  51. * example: null
  52. * nextPage:
  53. * type: number
  54. * example: null
  55. * hasNextPage:
  56. * type: boolean
  57. * example: false
  58. * hasPrevPage:
  59. * type: boolean
  60. * example: false
  61. * pagingCounter:
  62. * type: number
  63. * example: 1
  64. * Attachment:
  65. * description: Attachment
  66. * type: object
  67. * properties:
  68. * id:
  69. * type: string
  70. * description: attachment ID
  71. * example: 5e0734e072560e001761fa67
  72. * _id:
  73. * type: string
  74. * description: attachment ID
  75. * example: 5e0734e072560e001761fa67
  76. * __v:
  77. * type: number
  78. * description: attachment version
  79. * example: 0
  80. * attachmentType:
  81. * type: string
  82. * description: attachment type
  83. * example: WIKI_PAGE
  84. * fileFormat:
  85. * type: string
  86. * description: file format in MIME
  87. * example: text/plain
  88. * fileName:
  89. * type: string
  90. * description: file name
  91. * example: 601b7c59d43a042c0117e08dd37aad0aimage.txt
  92. * originalName:
  93. * type: string
  94. * description: original file name
  95. * example: file.txt
  96. * creator:
  97. * type: object
  98. * $ref: '#/components/schemas/User'
  99. * page:
  100. * type: string
  101. * description: page ID attached at
  102. * example: 5e07345972560e001761fa63
  103. * createdAt:
  104. * type: string
  105. * description: date created at
  106. * example: 2010-01-01T00:00:00.000Z
  107. * temporaryUrlExpiredAt:
  108. * type: string
  109. * description: temporary URL expired at
  110. * example: 2024-11-27T00:59:59.962Z
  111. * fileSize:
  112. * type: number
  113. * description: file size
  114. * example: 3494332
  115. * filePathProxied:
  116. * type: string
  117. * description: file path proxied
  118. * example: "/attachment/5e0734e072560e001761fa67"
  119. * downloadPathProxied:
  120. * type: string
  121. * description: download path proxied
  122. * example: "/download/5e0734e072560e001761fa67"
  123. * temporaryUrlCached:
  124. * type: string
  125. * description: temporary URL cached
  126. * example: "https://example.com/attachment/5e0734e072560e001761fa67"
  127. */
  128. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  129. module.exports = (crowi) => {
  130. const loginRequired = require('../../middlewares/login-required')(crowi, true);
  131. const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
  132. const Page = crowi.model('Page');
  133. const User = crowi.model('User');
  134. const { attachmentService } = crowi;
  135. const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
  136. const addActivity = generateAddActivityMiddleware(crowi);
  137. const activityEvent = crowi.event('activity');
  138. const validator = {
  139. retrieveAttachment: [
  140. param('id').isMongoId().withMessage('attachment id is required'),
  141. ],
  142. retrieveAttachments: [
  143. query('pageId').isMongoId().withMessage('pageId is required'),
  144. query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
  145. query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
  146. ],
  147. retrieveFileLimit: [
  148. query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
  149. ],
  150. retrieveAddAttachment: [
  151. body('page_id').isString().exists({ checkNull: true }).withMessage('page_id is required'),
  152. ],
  153. };
  154. /**
  155. * @swagger
  156. *
  157. * /attachment/list:
  158. * get:
  159. * tags: [Attachment]
  160. * description: Get attachment list
  161. * parameters:
  162. * - name: pageId
  163. * in: query
  164. * required: true
  165. * description: page id
  166. * schema:
  167. * type: string
  168. * - name: pageNumber
  169. * in: query
  170. * required: false
  171. * description: page number
  172. * schema:
  173. * type: number
  174. * example: 1
  175. * - name: limit
  176. * in: query
  177. * required: false
  178. * description: limit
  179. * schema:
  180. * type: number
  181. * example: 10
  182. * responses:
  183. * 200:
  184. * description: Return attachment list
  185. * content:
  186. * application/json:
  187. * schema:
  188. * type: object
  189. * $ref: '#/components/schemas/AttachmentPaginateResult'
  190. */
  191. router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
  192. const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
  193. const pageNumber = req.query.pageNumber || 1;
  194. const offset = (pageNumber - 1) * limit;
  195. try {
  196. const pageId = req.query.pageId;
  197. // check whether accessible
  198. const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
  199. if (!isAccessible) {
  200. const msg = 'Current user is not accessible to this page.';
  201. return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
  202. }
  203. // directly get paging-size from db. not to delivery from client side.
  204. const paginateResult = await Attachment.paginate(
  205. { page: pageId },
  206. {
  207. limit,
  208. offset,
  209. populate: 'creator',
  210. },
  211. );
  212. paginateResult.docs.forEach((doc) => {
  213. if (doc.creator != null && doc.creator instanceof User) {
  214. doc.creator = serializeUserSecurely(doc.creator);
  215. }
  216. });
  217. return res.apiv3({ paginateResult });
  218. }
  219. catch (err) {
  220. logger.error('Attachment not found', err);
  221. return res.apiv3Err(err, 500);
  222. }
  223. });
  224. /**
  225. * @swagger
  226. *
  227. * /attachment/limit:
  228. * get:
  229. * tags: [Attachment]
  230. * operationId: getAttachmentLimit
  231. * summary: /attachment/limit
  232. * description: Get available capacity of uploaded file with GridFS
  233. * parameters:
  234. * - in: query
  235. * name: fileSize
  236. * schema:
  237. * type: number
  238. * description: file size
  239. * example: 23175
  240. * required: true
  241. * responses:
  242. * 200:
  243. * description: Succeeded to get available capacity of uploaded file with GridFS.
  244. * content:
  245. * application/json:
  246. * schema:
  247. * properties:
  248. * isUploadable:
  249. * type: boolean
  250. * description: uploadable
  251. * example: true
  252. * 403:
  253. * $ref: '#/components/responses/403'
  254. * 500:
  255. * $ref: '#/components/responses/500'
  256. */
  257. router.get('/limit', accessTokenParser, loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
  258. const { fileUploadService } = crowi;
  259. const fileSize = Number(req.query.fileSize);
  260. try {
  261. return res.apiv3(await fileUploadService.checkLimit(fileSize));
  262. }
  263. catch (err) {
  264. logger.error('File limit retrieval failed', err);
  265. return res.apiv3Err(err, 500);
  266. }
  267. });
  268. /**
  269. * @swagger
  270. *
  271. * /attachment:
  272. * post:
  273. * tags: [Attachment]
  274. * operationId: addAttachment
  275. * summary: /attachment
  276. * description: Add attachment to the page
  277. * requestBody:
  278. * content:
  279. * "multipart/form-data":
  280. * schema:
  281. * properties:
  282. * page_id:
  283. * nullable: false
  284. * type: string
  285. * file:
  286. * type: string
  287. * format: binary
  288. * description: attachment data
  289. * encoding:
  290. * path:
  291. * contentType: application/x-www-form-urlencoded
  292. * "*\/*":
  293. * schema:
  294. * properties:
  295. * page_id:
  296. * nullable: false
  297. * type: string
  298. * file:
  299. * type: string
  300. * format: binary
  301. * description: attachment data
  302. * encoding:
  303. * path:
  304. * contentType: application/x-www-form-urlencoded
  305. * responses:
  306. * 200:
  307. * description: Succeeded to add attachment.
  308. * content:
  309. * application/json:
  310. * schema:
  311. * properties:
  312. * page:
  313. * $ref: '#/components/schemas/Page'
  314. * attachment:
  315. * $ref: '#/components/schemas/Attachment'
  316. * revision:
  317. * type: string
  318. * 403:
  319. * $ref: '#/components/responses/403'
  320. * 500:
  321. * $ref: '#/components/responses/500'
  322. */
  323. router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, uploads.single('file'), autoReap,
  324. validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
  325. async(req, res) => {
  326. const pageId = req.body.page_id;
  327. // check params
  328. const file = req.file || null;
  329. if (file == null) {
  330. return res.apiv3Err('File error.');
  331. }
  332. try {
  333. const page = await Page.findById(pageId);
  334. // check the user is accessible
  335. const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
  336. if (!isAccessible) {
  337. return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
  338. }
  339. const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
  340. const result = {
  341. page: serializePageSecurely(page),
  342. revision: serializeRevisionSecurely(page.revision),
  343. attachment: attachment.toObject({ virtuals: true }),
  344. };
  345. activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
  346. res.apiv3(result);
  347. }
  348. catch (err) {
  349. logger.error(err);
  350. return res.apiv3Err(err.message);
  351. }
  352. });
  353. /**
  354. * @swagger
  355. *
  356. * /attachment/{id}:
  357. * get:
  358. * tags: [Attachment]
  359. * description: Get attachment
  360. * responses:
  361. * 200:
  362. * description: Return attachment
  363. * content:
  364. * application/json:
  365. * schema:
  366. * type: object
  367. * properties:
  368. * attachment:
  369. * $ref: '#/components/schemas/Attachment'
  370. * parameters:
  371. * - name: id
  372. * in: path
  373. * required: true
  374. * description: attachment id
  375. * schema:
  376. * type: string
  377. */
  378. router.get('/:id', accessTokenParser, certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
  379. async(req, res) => {
  380. try {
  381. const attachmentId = req.params.id;
  382. const attachment = await Attachment.findById(attachmentId).populate('creator').exec();
  383. if (attachment == null) {
  384. const message = 'Attachment not found';
  385. return res.apiv3Err(message, 404);
  386. }
  387. if (attachment.creator != null && attachment.creator instanceof User) {
  388. attachment.creator = serializeUserSecurely(attachment.creator);
  389. }
  390. return res.apiv3({ attachment });
  391. }
  392. catch (err) {
  393. logger.error('Attachment retrieval failed', err);
  394. return res.apiv3Err(err, 500);
  395. }
  396. });
  397. return router;
  398. };