index.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import * as express from 'express'
  2. import { extname } from 'path'
  3. import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
  4. import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
  5. import { logger } from '../../../helpers/logger'
  6. import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
  7. import { getFormattedObjects } from '../../../helpers/utils'
  8. import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
  9. import {
  10. DEFAULT_AUDIO_RESOLUTION,
  11. MIMETYPES,
  12. VIDEO_CATEGORIES,
  13. VIDEO_LANGUAGES,
  14. VIDEO_LICENCES,
  15. VIDEO_PRIVACIES
  16. } from '../../../initializers/constants'
  17. import { federateVideoIfNeeded, fetchRemoteVideoDescription } from '../../../lib/activitypub/videos'
  18. import { JobQueue } from '../../../lib/job-queue'
  19. import { Redis } from '../../../lib/redis'
  20. import {
  21. asyncMiddleware,
  22. asyncRetryTransactionMiddleware,
  23. authenticate,
  24. checkVideoFollowConstraints,
  25. commonVideosFiltersValidator,
  26. optionalAuthenticate,
  27. paginationValidator,
  28. setDefaultPagination,
  29. setDefaultSort,
  30. videoFileMetadataGetValidator,
  31. videosAddValidator,
  32. videosCustomGetValidator,
  33. videosGetValidator,
  34. videosRemoveValidator,
  35. videosSortValidator,
  36. videosUpdateValidator
  37. } from '../../../middlewares'
  38. import { TagModel } from '../../../models/video/tag'
  39. import { VideoModel } from '../../../models/video/video'
  40. import { VideoFileModel } from '../../../models/video/video-file'
  41. import { abuseVideoRouter } from './abuse'
  42. import { blacklistRouter } from './blacklist'
  43. import { videoCommentRouter } from './comment'
  44. import { rateVideoRouter } from './rate'
  45. import { ownershipVideoRouter } from './ownership'
  46. import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
  47. import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils'
  48. import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
  49. import { videoCaptionsRouter } from './captions'
  50. import { videoImportsRouter } from './import'
  51. import { resetSequelizeInstance } from '../../../helpers/database-utils'
  52. import { move } from 'fs-extra'
  53. import { watchingRouter } from './watching'
  54. import { Notifier } from '../../../lib/notifier'
  55. import { sendView } from '../../../lib/activitypub/send/send-view'
  56. import { CONFIG } from '../../../initializers/config'
  57. import { sequelizeTypescript } from '../../../initializers/database'
  58. import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
  59. import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
  60. import { Hooks } from '../../../lib/plugins/hooks'
  61. import { MVideoDetails, MVideoFullLight } from '@server/types/models'
  62. import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
  63. import { getVideoFilePath } from '@server/lib/video-paths'
  64. import toInt from 'validator/lib/toInt'
  65. import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
  66. import { getServerActor } from '@server/models/application/application'
  67. import { changeVideoChannelShare } from '@server/lib/activitypub/share'
  68. import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
  69. const auditLogger = auditLoggerFactory('videos')
  70. const videosRouter = express.Router()
  71. const reqVideoFileAdd = createReqFiles(
  72. [ 'videofile', 'thumbnailfile', 'previewfile' ],
  73. Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
  74. {
  75. videofile: CONFIG.STORAGE.TMP_DIR,
  76. thumbnailfile: CONFIG.STORAGE.TMP_DIR,
  77. previewfile: CONFIG.STORAGE.TMP_DIR
  78. }
  79. )
  80. const reqVideoFileUpdate = createReqFiles(
  81. [ 'thumbnailfile', 'previewfile' ],
  82. MIMETYPES.IMAGE.MIMETYPE_EXT,
  83. {
  84. thumbnailfile: CONFIG.STORAGE.TMP_DIR,
  85. previewfile: CONFIG.STORAGE.TMP_DIR
  86. }
  87. )
  88. videosRouter.use('/', abuseVideoRouter)
  89. videosRouter.use('/', blacklistRouter)
  90. videosRouter.use('/', rateVideoRouter)
  91. videosRouter.use('/', videoCommentRouter)
  92. videosRouter.use('/', videoCaptionsRouter)
  93. videosRouter.use('/', videoImportsRouter)
  94. videosRouter.use('/', ownershipVideoRouter)
  95. videosRouter.use('/', watchingRouter)
  96. videosRouter.get('/categories', listVideoCategories)
  97. videosRouter.get('/licences', listVideoLicences)
  98. videosRouter.get('/languages', listVideoLanguages)
  99. videosRouter.get('/privacies', listVideoPrivacies)
  100. videosRouter.get('/',
  101. paginationValidator,
  102. videosSortValidator,
  103. setDefaultSort,
  104. setDefaultPagination,
  105. optionalAuthenticate,
  106. commonVideosFiltersValidator,
  107. asyncMiddleware(listVideos)
  108. )
  109. videosRouter.put('/:id',
  110. authenticate,
  111. reqVideoFileUpdate,
  112. asyncMiddleware(videosUpdateValidator),
  113. asyncRetryTransactionMiddleware(updateVideo)
  114. )
  115. videosRouter.post('/upload',
  116. authenticate,
  117. reqVideoFileAdd,
  118. asyncMiddleware(videosAddValidator),
  119. asyncRetryTransactionMiddleware(addVideo)
  120. )
  121. videosRouter.get('/:id/description',
  122. asyncMiddleware(videosGetValidator),
  123. asyncMiddleware(getVideoDescription)
  124. )
  125. videosRouter.get('/:id/metadata/:videoFileId',
  126. asyncMiddleware(videoFileMetadataGetValidator),
  127. asyncMiddleware(getVideoFileMetadata)
  128. )
  129. videosRouter.get('/:id',
  130. optionalAuthenticate,
  131. asyncMiddleware(videosCustomGetValidator('only-video-with-rights')),
  132. asyncMiddleware(checkVideoFollowConstraints),
  133. asyncMiddleware(getVideo)
  134. )
  135. videosRouter.post('/:id/views',
  136. asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
  137. asyncMiddleware(viewVideo)
  138. )
  139. videosRouter.delete('/:id',
  140. authenticate,
  141. asyncMiddleware(videosRemoveValidator),
  142. asyncRetryTransactionMiddleware(removeVideo)
  143. )
  144. // ---------------------------------------------------------------------------
  145. export {
  146. videosRouter
  147. }
  148. // ---------------------------------------------------------------------------
  149. function listVideoCategories (req: express.Request, res: express.Response) {
  150. res.json(VIDEO_CATEGORIES)
  151. }
  152. function listVideoLicences (req: express.Request, res: express.Response) {
  153. res.json(VIDEO_LICENCES)
  154. }
  155. function listVideoLanguages (req: express.Request, res: express.Response) {
  156. res.json(VIDEO_LANGUAGES)
  157. }
  158. function listVideoPrivacies (req: express.Request, res: express.Response) {
  159. res.json(VIDEO_PRIVACIES)
  160. }
  161. async function addVideo (req: express.Request, res: express.Response) {
  162. // Processing the video could be long
  163. // Set timeout to 10 minutes
  164. req.setTimeout(1000 * 60 * 10, () => {
  165. logger.error('Upload video has timed out.')
  166. return res.sendStatus(408)
  167. })
  168. const videoPhysicalFile = req.files['videofile'][0]
  169. const videoInfo: VideoCreate = req.body
  170. // Prepare data so we don't block the transaction
  171. const videoData = {
  172. name: videoInfo.name,
  173. remote: false,
  174. category: videoInfo.category,
  175. licence: videoInfo.licence,
  176. language: videoInfo.language,
  177. commentsEnabled: videoInfo.commentsEnabled !== false, // If the value is not "false", the default is "true"
  178. downloadEnabled: videoInfo.downloadEnabled !== false,
  179. waitTranscoding: videoInfo.waitTranscoding || false,
  180. state: CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED,
  181. nsfw: videoInfo.nsfw || false,
  182. description: videoInfo.description,
  183. support: videoInfo.support,
  184. privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
  185. duration: videoPhysicalFile['duration'], // duration was added by a previous middleware
  186. channelId: res.locals.videoChannel.id,
  187. originallyPublishedAt: videoInfo.originallyPublishedAt
  188. }
  189. const video = new VideoModel(videoData) as MVideoDetails
  190. video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
  191. const videoFile = new VideoFileModel({
  192. extname: extname(videoPhysicalFile.filename),
  193. size: videoPhysicalFile.size,
  194. videoStreamingPlaylistId: null,
  195. metadata: await getMetadataFromFile<any>(videoPhysicalFile.path)
  196. })
  197. if (videoFile.isAudio()) {
  198. videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
  199. } else {
  200. videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
  201. videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
  202. }
  203. // Move physical file
  204. const destination = getVideoFilePath(video, videoFile)
  205. await move(videoPhysicalFile.path, destination)
  206. // This is important in case if there is another attempt in the retry process
  207. videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
  208. videoPhysicalFile.path = destination
  209. // Process thumbnail or create it from the video
  210. const thumbnailField = req.files['thumbnailfile']
  211. const thumbnailModel = thumbnailField
  212. ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
  213. : await generateVideoMiniature(video, videoFile, ThumbnailType.MINIATURE)
  214. // Process preview or create it from the video
  215. const previewField = req.files['previewfile']
  216. const previewModel = previewField
  217. ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
  218. : await generateVideoMiniature(video, videoFile, ThumbnailType.PREVIEW)
  219. // Create the torrent file
  220. await createTorrentAndSetInfoHash(video, videoFile)
  221. const { videoCreated } = await sequelizeTypescript.transaction(async t => {
  222. const sequelizeOptions = { transaction: t }
  223. const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
  224. await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
  225. await videoCreated.addAndSaveThumbnail(previewModel, t)
  226. // Do not forget to add video channel information to the created video
  227. videoCreated.VideoChannel = res.locals.videoChannel
  228. videoFile.videoId = video.id
  229. await videoFile.save(sequelizeOptions)
  230. video.VideoFiles = [ videoFile ]
  231. // Create tags
  232. if (videoInfo.tags !== undefined) {
  233. const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
  234. await video.$set('Tags', tagInstances, sequelizeOptions)
  235. video.Tags = tagInstances
  236. }
  237. // Schedule an update in the future?
  238. if (videoInfo.scheduleUpdate) {
  239. await ScheduleVideoUpdateModel.create({
  240. videoId: video.id,
  241. updateAt: videoInfo.scheduleUpdate.updateAt,
  242. privacy: videoInfo.scheduleUpdate.privacy || null
  243. }, { transaction: t })
  244. }
  245. await autoBlacklistVideoIfNeeded({
  246. video,
  247. user: res.locals.oauth.token.User,
  248. isRemote: false,
  249. isNew: true,
  250. transaction: t
  251. })
  252. await federateVideoIfNeeded(video, true, t)
  253. auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
  254. logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
  255. return { videoCreated }
  256. })
  257. Notifier.Instance.notifyOnNewVideoIfNeeded(videoCreated)
  258. if (video.state === VideoState.TO_TRANSCODE) {
  259. await addOptimizeOrMergeAudioJob(videoCreated, videoFile)
  260. }
  261. Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
  262. return res.json({
  263. video: {
  264. id: videoCreated.id,
  265. uuid: videoCreated.uuid
  266. }
  267. }).end()
  268. }
  269. async function updateVideo (req: express.Request, res: express.Response) {
  270. const videoInstance = res.locals.videoAll
  271. const videoFieldsSave = videoInstance.toJSON()
  272. const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
  273. const videoInfoToUpdate: VideoUpdate = req.body
  274. const wasConfidentialVideo = videoInstance.isConfidential()
  275. const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
  276. // Process thumbnail or create it from the video
  277. const thumbnailModel = req.files?.['thumbnailfile']
  278. ? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE, false)
  279. : undefined
  280. const previewModel = req.files?.['previewfile']
  281. ? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW, false)
  282. : undefined
  283. try {
  284. const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
  285. const sequelizeOptions = { transaction: t }
  286. const oldVideoChannel = videoInstance.VideoChannel
  287. if (videoInfoToUpdate.name !== undefined) videoInstance.name = videoInfoToUpdate.name
  288. if (videoInfoToUpdate.category !== undefined) videoInstance.category = videoInfoToUpdate.category
  289. if (videoInfoToUpdate.licence !== undefined) videoInstance.licence = videoInfoToUpdate.licence
  290. if (videoInfoToUpdate.language !== undefined) videoInstance.language = videoInfoToUpdate.language
  291. if (videoInfoToUpdate.nsfw !== undefined) videoInstance.nsfw = videoInfoToUpdate.nsfw
  292. if (videoInfoToUpdate.waitTranscoding !== undefined) videoInstance.waitTranscoding = videoInfoToUpdate.waitTranscoding
  293. if (videoInfoToUpdate.support !== undefined) videoInstance.support = videoInfoToUpdate.support
  294. if (videoInfoToUpdate.description !== undefined) videoInstance.description = videoInfoToUpdate.description
  295. if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.commentsEnabled = videoInfoToUpdate.commentsEnabled
  296. if (videoInfoToUpdate.downloadEnabled !== undefined) videoInstance.downloadEnabled = videoInfoToUpdate.downloadEnabled
  297. if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
  298. videoInstance.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
  299. }
  300. let isNewVideo = false
  301. if (videoInfoToUpdate.privacy !== undefined) {
  302. isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
  303. const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10)
  304. videoInstance.setPrivacy(newPrivacy)
  305. // Unfederate the video if the new privacy is not compatible with federation
  306. if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
  307. await VideoModel.sendDelete(videoInstance, { transaction: t })
  308. }
  309. }
  310. const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) as MVideoFullLight
  311. if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
  312. if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
  313. // Video tags update?
  314. if (videoInfoToUpdate.tags !== undefined) {
  315. const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)
  316. await videoInstanceUpdated.$set('Tags', tagInstances, sequelizeOptions)
  317. videoInstanceUpdated.Tags = tagInstances
  318. }
  319. // Video channel update?
  320. if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
  321. await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
  322. videoInstanceUpdated.VideoChannel = res.locals.videoChannel
  323. if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
  324. }
  325. // Schedule an update in the future?
  326. if (videoInfoToUpdate.scheduleUpdate) {
  327. await ScheduleVideoUpdateModel.upsert({
  328. videoId: videoInstanceUpdated.id,
  329. updateAt: videoInfoToUpdate.scheduleUpdate.updateAt,
  330. privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
  331. }, { transaction: t })
  332. } else if (videoInfoToUpdate.scheduleUpdate === null) {
  333. await ScheduleVideoUpdateModel.deleteByVideoId(videoInstanceUpdated.id, t)
  334. }
  335. await autoBlacklistVideoIfNeeded({
  336. video: videoInstanceUpdated,
  337. user: res.locals.oauth.token.User,
  338. isRemote: false,
  339. isNew: false,
  340. transaction: t
  341. })
  342. await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
  343. auditLogger.update(
  344. getAuditIdFromRes(res),
  345. new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
  346. oldVideoAuditView
  347. )
  348. logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid)
  349. return videoInstanceUpdated
  350. })
  351. if (wasConfidentialVideo) {
  352. Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
  353. }
  354. Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated })
  355. } catch (err) {
  356. // Force fields we want to update
  357. // If the transaction is retried, sequelize will think the object has not changed
  358. // So it will skip the SQL request, even if the last one was ROLLBACKed!
  359. resetSequelizeInstance(videoInstance, videoFieldsSave)
  360. throw err
  361. }
  362. return res.type('json').status(204).end()
  363. }
  364. async function getVideo (req: express.Request, res: express.Response) {
  365. // We need more attributes
  366. const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
  367. const video = await Hooks.wrapPromiseFun(
  368. VideoModel.loadForGetAPI,
  369. { id: res.locals.onlyVideoWithRights.id, userId },
  370. 'filter:api.video.get.result'
  371. )
  372. if (video.isOutdated()) {
  373. JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
  374. }
  375. return res.json(video.toFormattedDetailsJSON())
  376. }
  377. async function viewVideo (req: express.Request, res: express.Response) {
  378. const videoInstance = res.locals.onlyImmutableVideo
  379. const ip = req.ip
  380. const exists = await Redis.Instance.doesVideoIPViewExist(ip, videoInstance.uuid)
  381. if (exists) {
  382. logger.debug('View for ip %s and video %s already exists.', ip, videoInstance.uuid)
  383. return res.status(204).end()
  384. }
  385. await Promise.all([
  386. Redis.Instance.addVideoView(videoInstance.id),
  387. Redis.Instance.setIPVideoView(ip, videoInstance.uuid)
  388. ])
  389. const serverActor = await getServerActor()
  390. await sendView(serverActor, videoInstance, undefined)
  391. Hooks.runAction('action:api.video.viewed', { video: videoInstance, ip })
  392. return res.status(204).end()
  393. }
  394. async function getVideoDescription (req: express.Request, res: express.Response) {
  395. const videoInstance = res.locals.videoAll
  396. let description = ''
  397. if (videoInstance.isOwned()) {
  398. description = videoInstance.description
  399. } else {
  400. description = await fetchRemoteVideoDescription(videoInstance)
  401. }
  402. return res.json({ description })
  403. }
  404. async function getVideoFileMetadata (req: express.Request, res: express.Response) {
  405. const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId))
  406. return res.json(videoFile.metadata)
  407. }
  408. async function listVideos (req: express.Request, res: express.Response) {
  409. const countVideos = getCountVideos(req)
  410. const apiOptions = await Hooks.wrapObject({
  411. start: req.query.start,
  412. count: req.query.count,
  413. sort: req.query.sort,
  414. includeLocalVideos: true,
  415. categoryOneOf: req.query.categoryOneOf,
  416. licenceOneOf: req.query.licenceOneOf,
  417. languageOneOf: req.query.languageOneOf,
  418. tagsOneOf: req.query.tagsOneOf,
  419. tagsAllOf: req.query.tagsAllOf,
  420. nsfw: buildNSFWFilter(res, req.query.nsfw),
  421. filter: req.query.filter as VideoFilter,
  422. withFiles: false,
  423. user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
  424. countVideos
  425. }, 'filter:api.videos.list.params')
  426. const resultList = await Hooks.wrapPromiseFun(
  427. VideoModel.listForApi,
  428. apiOptions,
  429. 'filter:api.videos.list.result'
  430. )
  431. return res.json(getFormattedObjects(resultList.data, resultList.total))
  432. }
  433. async function removeVideo (req: express.Request, res: express.Response) {
  434. const videoInstance = res.locals.videoAll
  435. await sequelizeTypescript.transaction(async t => {
  436. await videoInstance.destroy({ transaction: t })
  437. })
  438. auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
  439. logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
  440. Hooks.runAction('action:api.video.deleted', { video: videoInstance })
  441. return res.type('json').status(204).end()
  442. }