Browse Source

Add server hooks

Chocobozzz 4 months ago
parent
commit
b4055e1c23

+ 30
- 6
server/controllers/api/videos/comment.ts View File

@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
26 26
 import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
27 27
 import { AccountModel } from '../../../models/account/account'
28 28
 import { Notifier } from '../../../lib/notifier'
29
+import { Hooks } from '../../../lib/plugins/hooks'
29 30
 
30 31
 const auditLogger = auditLoggerFactory('comments')
31 32
 const videoCommentRouter = express.Router()
@@ -76,7 +77,18 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
76 77
   let resultList: ResultList<VideoCommentModel>
77 78
 
78 79
   if (video.commentsEnabled === true) {
79
-    resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort, user)
80
+    const apiOptions = await Hooks.wrapObject({
81
+      videoId: video.id,
82
+      start: req.query.start,
83
+      count: req.query.count,
84
+      sort: req.query.sort,
85
+      user: user
86
+    }, 'filter:api.video-threads.list.params')
87
+
88
+    resultList = await Hooks.wrapPromise(
89
+      VideoCommentModel.listThreadsForApi(apiOptions),
90
+      'filter:api.video-threads.list.result'
91
+    )
80 92
   } else {
81 93
     resultList = {
82 94
       total: 0,
@@ -94,7 +106,16 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
94 106
   let resultList: ResultList<VideoCommentModel>
95 107
 
96 108
   if (video.commentsEnabled === true) {
97
-    resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id, user)
109
+    const apiOptions = await Hooks.wrapObject({
110
+      videoId: video.id,
111
+      threadId: res.locals.videoCommentThread.id,
112
+      user: user
113
+    }, 'filter:api.video-thread-comments.list.params')
114
+
115
+    resultList = await Hooks.wrapPromise(
116
+      VideoCommentModel.listThreadCommentsForApi(apiOptions),
117
+      'filter:api.video-thread-comments.list.result'
118
+    )
98 119
   } else {
99 120
     resultList = {
100 121
       total: 0,
@@ -122,6 +143,8 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
122 143
   Notifier.Instance.notifyOnNewComment(comment)
123 144
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
124 145
 
146
+  Hooks.runAction('action:api.video-thread.created', { comment })
147
+
125 148
   return res.json({
126 149
     comment: comment.toFormattedJSON()
127 150
   }).end()
@@ -144,6 +167,8 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
144 167
   Notifier.Instance.notifyOnNewComment(comment)
145 168
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
146 169
 
170
+  Hooks.runAction('action:api.video-comment-reply.created', { comment })
171
+
147 172
   return res.json({ comment: comment.toFormattedJSON() }).end()
148 173
 }
149 174
 
@@ -154,11 +179,10 @@ async function removeVideoComment (req: express.Request, res: express.Response)
154 179
     await videoCommentInstance.destroy({ transaction: t })
155 180
   })
156 181
 
157
-  auditLogger.delete(
158
-    getAuditIdFromRes(res),
159
-    new CommentAuditView(videoCommentInstance.toFormattedJSON())
160
-  )
182
+  auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
161 183
   logger.info('Video comment %d deleted.', videoCommentInstance.id)
162 184
 
185
+  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance })
186
+
163 187
   return res.type('json').status(204).end()
164 188
 }

+ 24
- 12
server/controllers/api/videos/index.ts View File

@@ -62,6 +62,7 @@ import { sequelizeTypescript } from '../../../initializers/database'
62 62
 import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
63 63
 import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
64 64
 import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding'
65
+import { Hooks } from '../../../lib/plugins/hooks'
65 66
 
66 67
 const auditLogger = auditLoggerFactory('videos')
67 68
 const videosRouter = express.Router()
@@ -268,10 +269,7 @@ async function addVideo (req: express.Request, res: express.Response) {
268 269
     }
269 270
 
270 271
     const videoWasAutoBlacklisted = await autoBlacklistVideoIfNeeded(video, res.locals.oauth.token.User, t)
271
-
272
-    if (!videoWasAutoBlacklisted) {
273
-      await federateVideoIfNeeded(video, true, t)
274
-    }
272
+    if (!videoWasAutoBlacklisted) await federateVideoIfNeeded(video, true, t)
275 273
 
276 274
     auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
277 275
     logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
@@ -279,11 +277,8 @@ async function addVideo (req: express.Request, res: express.Response) {
279 277
     return { videoCreated, videoWasAutoBlacklisted }
280 278
   })
281 279
 
282
-  if (videoWasAutoBlacklisted) {
283
-    Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated)
284
-  } else {
285
-    Notifier.Instance.notifyOnNewVideo(videoCreated)
286
-  }
280
+  if (videoWasAutoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated)
281
+  else Notifier.Instance.notifyOnNewVideo(videoCreated)
287 282
 
288 283
   if (video.state === VideoState.TO_TRANSCODE) {
289 284
     // Put uuid because we don't have id auto incremented for now
@@ -307,6 +302,8 @@ async function addVideo (req: express.Request, res: express.Response) {
307 302
     await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
308 303
   }
309 304
 
305
+  Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
306
+
310 307
   return res.json({
311 308
     video: {
312 309
       id: videoCreated.id,
@@ -421,6 +418,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
421 418
     if (wasUnlistedVideo || wasPrivateVideo) {
422 419
       Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
423 420
     }
421
+
422
+    Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated })
424 423
   } catch (err) {
425 424
     // Force fields we want to update
426 425
     // If the transaction is retried, sequelize will think the object has not changed
@@ -436,7 +435,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
436 435
 async function getVideo (req: express.Request, res: express.Response) {
437 436
   // We need more attributes
438 437
   const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
439
-  const video = await VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId)
438
+
439
+  const video = await Hooks.wrapPromise(
440
+    VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId),
441
+    'filter:api.video.get.result'
442
+  )
440 443
 
441 444
   if (video.isOutdated()) {
442 445
     JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
@@ -464,6 +467,8 @@ async function viewVideo (req: express.Request, res: express.Response) {
464 467
   const serverActor = await getServerActor()
465 468
   await sendView(serverActor, videoInstance, undefined)
466 469
 
470
+  Hooks.runAction('action:api.video.viewed', { video: videoInstance, ip })
471
+
467 472
   return res.status(204).end()
468 473
 }
469 474
 
@@ -481,7 +486,7 @@ async function getVideoDescription (req: express.Request, res: express.Response)
481 486
 }
482 487
 
483 488
 async function listVideos (req: express.Request, res: express.Response) {
484
-  const resultList = await VideoModel.listForApi({
489
+  const apiOptions = await Hooks.wrapObject({
485 490
     start: req.query.start,
486 491
     count: req.query.count,
487 492
     sort: req.query.sort,
@@ -495,7 +500,12 @@ async function listVideos (req: express.Request, res: express.Response) {
495 500
     filter: req.query.filter as VideoFilter,
496 501
     withFiles: false,
497 502
     user: res.locals.oauth ? res.locals.oauth.token.User : undefined
498
-  })
503
+  }, 'filter:api.videos.list.params')
504
+
505
+  const resultList = await Hooks.wrapPromise(
506
+    VideoModel.listForApi(apiOptions),
507
+    'filter:api.videos.list.result'
508
+  )
499 509
 
500 510
   return res.json(getFormattedObjects(resultList.data, resultList.total))
501 511
 }
@@ -510,5 +520,7 @@ async function removeVideo (req: express.Request, res: express.Response) {
510 520
   auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
511 521
   logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
512 522
 
523
+  Hooks.runAction('action:api.video.deleted', { video: videoInstance })
524
+
513 525
   return res.type('json').status(204).end()
514 526
 }

+ 1
- 1
server/helpers/core-utils.ts View File

@@ -141,7 +141,7 @@ function root () {
141 141
   const paths = [ __dirname, '..', '..' ]
142 142
 
143 143
   // We are under /dist directory
144
-  if (process.mainModule && process.mainModule.filename.endsWith('.ts') === false) {
144
+  if (process.mainModule && process.mainModule.filename.endsWith('_mocha') === false) {
145 145
     paths.push('..')
146 146
   }
147 147
 

+ 1
- 1
server/lib/activitypub/video-comments.ts View File

@@ -134,7 +134,7 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []):
134 134
     })
135 135
 
136 136
     if (sanitizeAndCheckVideoCommentObject(body) === false) {
137
-      throw new Error('Remote video comment JSON is not valid :' + JSON.stringify(body))
137
+      throw new Error('Remote video comment JSON is not valid:' + JSON.stringify(body))
138 138
     }
139 139
 
140 140
     const actorUrl = body.attributedTo

+ 67
- 65
server/lib/activitypub/videos.ts View File

@@ -54,6 +54,8 @@ import { ThumbnailModel } from '../../models/video/thumbnail'
54 54
 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
55 55
 import { join } from 'path'
56 56
 import { FilteredModelAttributes } from '../../typings/sequelize'
57
+import { Hooks } from '../plugins/hooks'
58
+import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
57 59
 
58 60
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
59 61
   // If the video is not private and is published, we federate it
@@ -236,72 +238,74 @@ async function updateVideoFromAP (options: {
236 238
   channel: VideoChannelModel,
237 239
   overrideTo?: string[]
238 240
 }) {
241
+  const { video, videoObject, account, channel, overrideTo } = options
242
+
239 243
   logger.debug('Updating remote video "%s".', options.videoObject.uuid)
240 244
 
241 245
   let videoFieldsSave: any
242
-  const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
243
-  const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
246
+  const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
247
+  const wasUnlistedVideo = video.privacy === VideoPrivacy.UNLISTED
244 248
 
245 249
   try {
246 250
     let thumbnailModel: ThumbnailModel
247 251
 
248 252
     try {
249
-      thumbnailModel = await createVideoMiniatureFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.MINIATURE)
253
+      thumbnailModel = await createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE)
250 254
     } catch (err) {
251
-      logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
255
+      logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })
252 256
     }
253 257
 
254 258
     await sequelizeTypescript.transaction(async t => {
255 259
       const sequelizeOptions = { transaction: t }
256 260
 
257
-      videoFieldsSave = options.video.toJSON()
261
+      videoFieldsSave = video.toJSON()
258 262
 
259 263
       // Check actor has the right to update the video
260
-      const videoChannel = options.video.VideoChannel
261
-      if (videoChannel.Account.id !== options.account.id) {
262
-        throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
264
+      const videoChannel = video.VideoChannel
265
+      if (videoChannel.Account.id !== account.id) {
266
+        throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
263 267
       }
264 268
 
265
-      const to = options.overrideTo ? options.overrideTo : options.videoObject.to
266
-      const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
267
-      options.video.set('name', videoData.name)
268
-      options.video.set('uuid', videoData.uuid)
269
-      options.video.set('url', videoData.url)
270
-      options.video.set('category', videoData.category)
271
-      options.video.set('licence', videoData.licence)
272
-      options.video.set('language', videoData.language)
273
-      options.video.set('description', videoData.description)
274
-      options.video.set('support', videoData.support)
275
-      options.video.set('nsfw', videoData.nsfw)
276
-      options.video.set('commentsEnabled', videoData.commentsEnabled)
277
-      options.video.set('downloadEnabled', videoData.downloadEnabled)
278
-      options.video.set('waitTranscoding', videoData.waitTranscoding)
279
-      options.video.set('state', videoData.state)
280
-      options.video.set('duration', videoData.duration)
281
-      options.video.set('createdAt', videoData.createdAt)
282
-      options.video.set('publishedAt', videoData.publishedAt)
283
-      options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
284
-      options.video.set('privacy', videoData.privacy)
285
-      options.video.set('channelId', videoData.channelId)
286
-      options.video.set('views', videoData.views)
287
-
288
-      await options.video.save(sequelizeOptions)
289
-
290
-      if (thumbnailModel) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t)
269
+      const to = overrideTo ? overrideTo : videoObject.to
270
+      const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
271
+      video.name = videoData.name
272
+      video.uuid = videoData.uuid
273
+      video.url = videoData.url
274
+      video.category = videoData.category
275
+      video.licence = videoData.licence
276
+      video.language = videoData.language
277
+      video.description = videoData.description
278
+      video.support = videoData.support
279
+      video.nsfw = videoData.nsfw
280
+      video.commentsEnabled = videoData.commentsEnabled
281
+      video.downloadEnabled = videoData.downloadEnabled
282
+      video.waitTranscoding = videoData.waitTranscoding
283
+      video.state = videoData.state
284
+      video.duration = videoData.duration
285
+      video.createdAt = videoData.createdAt
286
+      video.publishedAt = videoData.publishedAt
287
+      video.originallyPublishedAt = videoData.originallyPublishedAt
288
+      video.privacy = videoData.privacy
289
+      video.channelId = videoData.channelId
290
+      video.views = videoData.views
291
+
292
+      await video.save(sequelizeOptions)
293
+
294
+      if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
291 295
 
292 296
       // FIXME: use icon URL instead
293
-      const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
294
-      const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
295
-      await options.video.addAndSaveThumbnail(previewModel, t)
297
+      const previewUrl = buildRemoteBaseUrl(video, join(STATIC_PATHS.PREVIEWS, video.getPreview().filename))
298
+      const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
299
+      await video.addAndSaveThumbnail(previewModel, t)
296 300
 
297 301
       {
298
-        const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
302
+        const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
299 303
         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
300 304
 
301 305
         // Remove video files that do not exist anymore
302
-        const destroyTasks = options.video.VideoFiles
303
-                                    .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
304
-                                    .map(f => f.destroy(sequelizeOptions))
306
+        const destroyTasks = video.VideoFiles
307
+                                  .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
308
+                                  .map(f => f.destroy(sequelizeOptions))
305 309
         await Promise.all(destroyTasks)
306 310
 
307 311
         // Update or add other one
@@ -310,21 +314,17 @@ async function updateVideoFromAP (options: {
310 314
             .then(([ file ]) => file)
311 315
         })
312 316
 
313
-        options.video.VideoFiles = await Promise.all(upsertTasks)
317
+        video.VideoFiles = await Promise.all(upsertTasks)
314 318
       }
315 319
 
316 320
       {
317
-        const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
318
-          options.video,
319
-          options.videoObject,
320
-          options.video.VideoFiles
321
-        )
321
+        const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(video, videoObject, video.VideoFiles)
322 322
         const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
323 323
 
324 324
         // Remove video files that do not exist anymore
325
-        const destroyTasks = options.video.VideoStreamingPlaylists
326
-                                    .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
327
-                                    .map(f => f.destroy(sequelizeOptions))
325
+        const destroyTasks = video.VideoStreamingPlaylists
326
+                                  .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
327
+                                  .map(f => f.destroy(sequelizeOptions))
328 328
         await Promise.all(destroyTasks)
329 329
 
330 330
         // Update or add other one
@@ -333,36 +333,36 @@ async function updateVideoFromAP (options: {
333 333
                                .then(([ streamingPlaylist ]) => streamingPlaylist)
334 334
         })
335 335
 
336
-        options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
336
+        video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
337 337
       }
338 338
 
339 339
       {
340 340
         // Update Tags
341
-        const tags = options.videoObject.tag.map(tag => tag.name)
341
+        const tags = videoObject.tag.map(tag => tag.name)
342 342
         const tagInstances = await TagModel.findOrCreateTags(tags, t)
343
-        await options.video.$set('Tags', tagInstances, sequelizeOptions)
343
+        await video.$set('Tags', tagInstances, sequelizeOptions)
344 344
       }
345 345
 
346 346
       {
347 347
         // Update captions
348
-        await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
348
+        await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
349 349
 
350
-        const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
351
-          return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
350
+        const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
351
+          return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
352 352
         })
353
-        options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
353
+        video.VideoCaptions = await Promise.all(videoCaptionsPromises)
354 354
       }
355 355
     })
356 356
 
357
-    // Notify our users?
358
-    if (wasPrivateVideo || wasUnlistedVideo) {
359
-      Notifier.Instance.notifyOnNewVideo(options.video)
360
-    }
357
+    const autoBlacklisted = await autoBlacklistVideoIfNeeded(video, undefined, undefined)
358
+
359
+    if (autoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(video)
360
+    else if (!wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideo(video) // Notify our users?
361 361
 
362
-    logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
362
+    logger.info('Remote video with uuid %s updated', videoObject.uuid)
363 363
   } catch (err) {
364
-    if (options.video !== undefined && videoFieldsSave !== undefined) {
365
-      resetSequelizeInstance(options.video, videoFieldsSave)
364
+    if (video !== undefined && videoFieldsSave !== undefined) {
365
+      resetSequelizeInstance(video, videoFieldsSave)
366 366
     }
367 367
 
368 368
     // This is just a debug because we will retry the insert
@@ -379,7 +379,9 @@ async function refreshVideoIfNeeded (options: {
379 379
   if (!options.video.isOutdated()) return options.video
380 380
 
381 381
   // We need more attributes if the argument video was fetched with not enough joints
382
-  const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
382
+  const video = options.fetchedType === 'all'
383
+    ? options.video
384
+    : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
383 385
 
384 386
   try {
385 387
     const { response, videoObject } = await fetchRemoteVideo(video.url)

+ 64
- 0
server/lib/moderation.ts View File

@@ -0,0 +1,64 @@
1
+import { VideoModel } from '../models/video/video'
2
+import { VideoCommentModel } from '../models/video/video-comment'
3
+import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
4
+import { VideoCreate } from '../../shared/models/videos'
5
+import { UserModel } from '../models/account/user'
6
+import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
7
+import { ActivityCreate } from '../../shared/models/activitypub'
8
+import { ActorModel } from '../models/activitypub/actor'
9
+import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
10
+
11
+export type AcceptResult = {
12
+  accepted: boolean
13
+  errorMessage?: string
14
+}
15
+
16
+// Can be filtered by plugins
17
+function isLocalVideoAccepted (object: {
18
+  videoBody: VideoCreate,
19
+  videoFile: Express.Multer.File & { duration?: number },
20
+  user: UserModel
21
+}): AcceptResult {
22
+  return { accepted: true }
23
+}
24
+
25
+function isLocalVideoThreadAccepted (_object: {
26
+  commentBody: VideoCommentCreate,
27
+  video: VideoModel,
28
+  user: UserModel
29
+}): AcceptResult {
30
+  return { accepted: true }
31
+}
32
+
33
+function isLocalVideoCommentReplyAccepted (_object: {
34
+  commentBody: VideoCommentCreate,
35
+  parentComment: VideoCommentModel,
36
+  video: VideoModel,
37
+  user: UserModel
38
+}): AcceptResult {
39
+  return { accepted: true }
40
+}
41
+
42
+function isRemoteVideoAccepted (_object: {
43
+  activity: ActivityCreate,
44
+  videoAP: VideoTorrentObject,
45
+  byActor: ActorModel
46
+}): AcceptResult {
47
+  return { accepted: true }
48
+}
49
+
50
+function isRemoteVideoCommentAccepted (_object: {
51
+  activity: ActivityCreate,
52
+  commentAP: VideoCommentObject,
53
+  byActor: ActorModel
54
+}): AcceptResult {
55
+  return { accepted: true }
56
+}
57
+
58
+export {
59
+  isLocalVideoAccepted,
60
+  isLocalVideoThreadAccepted,
61
+  isRemoteVideoAccepted,
62
+  isRemoteVideoCommentAccepted,
63
+  isLocalVideoCommentReplyAccepted
64
+}

+ 26
- 0
server/lib/plugins/hooks.ts View File

@@ -0,0 +1,26 @@
1
+import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models/plugins/server-hook.model'
2
+import { PluginManager } from './plugin-manager'
3
+import { logger } from '../../helpers/logger'
4
+import * as Bluebird from 'bluebird'
5
+
6
+// Helpers to run hooks
7
+const Hooks = {
8
+  wrapObject: <T, U extends ServerFilterHookName>(obj: T, hookName: U) => {
9
+    return PluginManager.Instance.runHook(hookName, obj) as Promise<T>
10
+  },
11
+
12
+  wrapPromise: async <T, U extends ServerFilterHookName>(fun: Promise<T> | Bluebird<T>, hookName: U) => {
13
+    const result = await fun
14
+
15
+    return PluginManager.Instance.runHook(hookName, result)
16
+  },
17
+
18
+  runAction: <T, U extends ServerActionHookName>(hookName: U, params?: T) => {
19
+    PluginManager.Instance.runHook(hookName, params)
20
+      .catch(err => logger.error('Fatal hook error.', { err }))
21
+  }
22
+}
23
+
24
+export {
25
+  Hooks
26
+}

+ 9
- 13
server/lib/plugins/plugin-manager.ts View File

@@ -14,6 +14,10 @@ import { RegisterSettingOptions } from '../../../shared/models/plugins/register-
14 14
 import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model'
15 15
 import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
16 16
 import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model'
17
+import { ServerHookName, ServerHook } from '../../../shared/models/plugins/server-hook.model'
18
+import { isCatchable, isPromise } from '../../../shared/core-utils/miscs/miscs'
19
+import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
20
+import { HookType } from '../../../shared/models/plugins/hook-type.enum'
17 21
 
18 22
 export interface RegisteredPlugin {
19 23
   npmName: string
@@ -42,7 +46,7 @@ export interface HookInformationValue {
42 46
   priority: number
43 47
 }
44 48
 
45
-export class PluginManager {
49
+export class PluginManager implements ServerHook {
46 50
 
47 51
   private static instance: PluginManager
48 52
 
@@ -95,25 +99,17 @@ export class PluginManager {
95 99
 
96 100
   // ###################### Hooks ######################
97 101
 
98
-  async runHook (hookName: string, param?: any) {
102
+  async runHook (hookName: ServerHookName, param?: any) {
99 103
     let result = param
100 104
 
101 105
     if (!this.hooks[hookName]) return result
102 106
 
103
-    const wait = hookName.startsWith('static:')
107
+    const hookType = getHookType(hookName)
104 108
 
105 109
     for (const hook of this.hooks[hookName]) {
106
-      try {
107
-        const p = hook.handler(param)
108
-
109
-        if (wait) {
110
-          result = await p
111
-        } else if (p.catch) {
112
-          p.catch(err => logger.warn('Hook %s of plugin %s thrown an error.', hookName, hook.pluginName, { err }))
113
-        }
114
-      } catch (err) {
110
+      result = await internalRunHook(hook.handler, hookType, param, err => {
115 111
         logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err })
116
-      }
112
+      })
117 113
     }
118 114
 
119 115
     return result

+ 19
- 6
server/lib/video-blacklist.ts View File

@@ -1,4 +1,4 @@
1
-import * as sequelize from 'sequelize'
1
+import { Transaction } from 'sequelize'
2 2
 import { CONFIG } from '../initializers/config'
3 3
 import { UserRight, VideoBlacklistType } from '../../shared/models'
4 4
 import { VideoBlacklistModel } from '../models/video/video-blacklist'
@@ -6,26 +6,39 @@ import { UserModel } from '../models/account/user'
6 6
 import { VideoModel } from '../models/video/video'
7 7
 import { logger } from '../helpers/logger'
8 8
 import { UserAdminFlag } from '../../shared/models/users/user-flag.model'
9
+import { Hooks } from './plugins/hooks'
9 10
 
10
-async function autoBlacklistVideoIfNeeded (video: VideoModel, user: UserModel, transaction: sequelize.Transaction) {
11
-  if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED) return false
11
+async function autoBlacklistVideoIfNeeded (video: VideoModel, user?: UserModel, transaction?: Transaction) {
12
+  const doAutoBlacklist = await Hooks.wrapPromise(
13
+    autoBlacklistNeeded({ video, user }),
14
+    'filter:video.auto-blacklist.result'
15
+  )
12 16
 
13
-  if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST)) return false
17
+  if (!doAutoBlacklist) return false
14 18
 
15
-  const sequelizeOptions = { transaction }
16 19
   const videoBlacklistToCreate = {
17 20
     videoId: video.id,
18 21
     unfederated: true,
19 22
     reason: 'Auto-blacklisted. Moderator review required.',
20 23
     type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED
21 24
   }
22
-  await VideoBlacklistModel.create(videoBlacklistToCreate, sequelizeOptions)
25
+  await VideoBlacklistModel.create(videoBlacklistToCreate, { transaction })
23 26
 
24 27
   logger.info('Video %s auto-blacklisted.', video.uuid)
25 28
 
26 29
   return true
27 30
 }
28 31
 
32
+async function autoBlacklistNeeded (parameters: { video: VideoModel, user?: UserModel }) {
33
+  const { user } = parameters
34
+
35
+  if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
36
+
37
+  if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST)) return false
38
+
39
+  return true
40
+}
41
+
29 42
 // ---------------------------------------------------------------------------
30 43
 
31 44
 export {

+ 38
- 0
server/middlewares/validators/videos/video-comments.ts View File

@@ -9,6 +9,8 @@ import { UserModel } from '../../../models/account/user'
9 9
 import { VideoModel } from '../../../models/video/video'
10 10
 import { VideoCommentModel } from '../../../models/video/video-comment'
11 11
 import { areValidationErrors } from '../utils'
12
+import { Hooks } from '../../../lib/plugins/hooks'
13
+import { isLocalVideoThreadAccepted, isLocalVideoCommentReplyAccepted, AcceptResult } from '../../../lib/moderation'
12 14
 
13 15
 const listVideoCommentThreadsValidator = [
14 16
   param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -48,6 +50,7 @@ const addVideoCommentThreadValidator = [
48 50
     if (areValidationErrors(req, res)) return
49 51
     if (!await doesVideoExist(req.params.videoId, res)) return
50 52
     if (!isVideoCommentsEnabled(res.locals.video, res)) return
53
+    if (!await isVideoCommentAccepted(req, res, false)) return
51 54
 
52 55
     return next()
53 56
   }
@@ -65,6 +68,7 @@ const addVideoCommentReplyValidator = [
65 68
     if (!await doesVideoExist(req.params.videoId, res)) return
66 69
     if (!isVideoCommentsEnabled(res.locals.video, res)) return
67 70
     if (!await doesVideoCommentExist(req.params.commentId, res.locals.video, res)) return
71
+    if (!await isVideoCommentAccepted(req, res, true)) return
68 72
 
69 73
     return next()
70 74
   }
@@ -193,3 +197,37 @@ function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCom
193 197
 
194 198
   return true
195 199
 }
200
+
201
+async function isVideoCommentAccepted (req: express.Request, res: express.Response, isReply: boolean) {
202
+  const acceptParameters = {
203
+    video: res.locals.video,
204
+    commentBody: req.body,
205
+    user: res.locals.oauth.token.User
206
+  }
207
+
208
+  let acceptedResult: AcceptResult
209
+
210
+  if (isReply) {
211
+    const acceptReplyParameters = Object.assign(acceptParameters, { parentComment: res.locals.videoComment })
212
+
213
+    acceptedResult = await Hooks.wrapObject(
214
+      isLocalVideoCommentReplyAccepted(acceptReplyParameters),
215
+      'filter:api.video-comment-reply.create.accept.result'
216
+    )
217
+  } else {
218
+    acceptedResult = await Hooks.wrapObject(
219
+      isLocalVideoThreadAccepted(acceptParameters),
220
+      'filter:api.video-thread.create.accept.result'
221
+    )
222
+  }
223
+
224
+  if (!acceptedResult || acceptedResult.accepted !== true) {
225
+    logger.info('Refused local comment.', { acceptedResult, acceptParameters })
226
+    res.status(403)
227
+              .json({ error: acceptedResult.errorMessage || 'Refused local comment' })
228
+
229
+    return false
230
+  }
231
+
232
+  return true
233
+}

+ 31
- 6
server/middlewares/validators/videos/videos.ts View File

@@ -33,7 +33,7 @@ import {
33 33
 import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
34 34
 import { logger } from '../../../helpers/logger'
35 35
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
36
-import { authenticatePromiseIfNeeded } from '../../oauth'
36
+import { authenticate, authenticatePromiseIfNeeded } from '../../oauth'
37 37
 import { areValidationErrors } from '../utils'
38 38
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
39 39
 import { VideoModel } from '../../../models/video/video'
@@ -44,6 +44,8 @@ import { VideoFetchType } from '../../../helpers/video'
44 44
 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
45 45
 import { getServerActor } from '../../../helpers/utils'
46 46
 import { CONFIG } from '../../../initializers/config'
47
+import { isLocalVideoAccepted } from '../../../lib/moderation'
48
+import { Hooks } from '../../../lib/plugins/hooks'
47 49
 
48 50
 const videosAddValidator = getCommonVideoEditAttributes().concat([
49 51
   body('videofile')
@@ -62,14 +64,12 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
62 64
     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
63 65
     if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
64 66
 
65
-    const videoFile: Express.Multer.File = req.files['videofile'][0]
67
+    const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
66 68
     const user = res.locals.oauth.token.User
67 69
 
68 70
     if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
69 71
 
70
-    const isAble = await user.isAbleToUploadVideo(videoFile)
71
-
72
-    if (isAble === false) {
72
+    if (await user.isAbleToUploadVideo(videoFile) === false) {
73 73
       res.status(403)
74 74
          .json({ error: 'The user video quota is exceeded with this video.' })
75 75
 
@@ -88,7 +88,9 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
88 88
       return cleanUpReqFiles(req)
89 89
     }
90 90
 
91
-    videoFile['duration'] = duration
91
+    videoFile.duration = duration
92
+
93
+    if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
92 94
 
93 95
     return next()
94 96
   }
@@ -434,3 +436,26 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
434 436
 
435 437
   return false
436 438
 }
439
+
440
+async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
441
+  // Check we accept this video
442
+  const acceptParameters = {
443
+    videoBody: req.body,
444
+    videoFile,
445
+    user: res.locals.oauth.token.User
446
+  }
447
+  const acceptedResult = await Hooks.wrapObject(
448
+    isLocalVideoAccepted(acceptParameters),
449
+    'filter:api.video.upload.accept.result'
450
+  )
451
+
452
+  if (!acceptedResult || acceptedResult.accepted !== true) {
453
+    logger.info('Refused local video.', { acceptedResult, acceptParameters })
454
+    res.status(403)
455
+       .json({ error: acceptedResult.errorMessage || 'Refused local video' })
456
+
457
+    return false
458
+  }
459
+
460
+  return true
461
+}

+ 16
- 2
server/models/video/video-comment.ts View File

@@ -293,7 +293,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
293 293
     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
294 294
   }
295 295
 
296
-  static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
296
+  static async listThreadsForApi (parameters: {
297
+    videoId: number,
298
+    start: number,
299
+    count: number,
300
+    sort: string,
301
+    user?: UserModel
302
+  }) {
303
+    const { videoId, start, count, sort, user } = parameters
304
+
297 305
     const serverActor = await getServerActor()
298 306
     const serverAccountId = serverActor.Account.id
299 307
     const userAccountId = user ? user.Account.id : undefined
@@ -328,7 +336,13 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
328 336
       })
329 337
   }
330 338
 
331
-  static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
339
+  static async listThreadCommentsForApi (parameters: {
340
+    videoId: number,
341
+    threadId: number,
342
+    user?: UserModel
343
+  }) {
344
+    const { videoId, threadId, user } = parameters
345
+
332 346
     const serverActor = await getServerActor()
333 347
     const serverAccountId = serverActor.Account.id
334 348
     const userAccountId = user ? user.Account.id : undefined

+ 11
- 1
shared/core-utils/miscs/miscs.ts View File

@@ -19,7 +19,17 @@ function compareSemVer (a: string, b: string) {
19 19
   return segmentsA.length - segmentsB.length
20 20
 }
21 21
 
22
+function isPromise (value: any) {
23
+  return value && typeof value.then === 'function'
24
+}
25
+
26
+function isCatchable (value: any) {
27
+  return value && typeof value.catch === 'function'
28
+}
29
+
22 30
 export {
23 31
   randomInt,
24
-  compareSemVer
32
+  compareSemVer,
33
+  isPromise,
34
+  isCatchable
25 35
 }

+ 41
- 0
shared/core-utils/plugins/hooks.ts View File

@@ -0,0 +1,41 @@
1
+import { HookType } from '../../models/plugins/hook-type.enum'
2
+import { isCatchable, isPromise } from '../miscs/miscs'
3
+
4
+function getHookType (hookName: string) {
5
+  if (hookName.startsWith('filter:')) return HookType.FILTER
6
+  if (hookName.startsWith('action:')) return HookType.ACTION
7
+
8
+  return HookType.STATIC
9
+}
10
+
11
+async function internalRunHook (handler: Function, hookType: HookType, param: any, onError: (err: Error) => void) {
12
+  let result = param
13
+
14
+  try {
15
+    const p = handler(result)
16
+
17
+    switch (hookType) {
18
+      case HookType.FILTER:
19
+        if (isPromise(p)) result = await p
20
+        else result = p
21
+        break
22
+
23
+      case HookType.STATIC:
24
+        if (isPromise(p)) await p
25
+        break
26
+
27
+      case HookType.ACTION:
28
+        if (isCatchable(p)) p.catch(err => onError(err))
29
+        break
30
+    }
31
+  } catch (err) {
32
+    onError(err)
33
+  }
34
+
35
+  return result
36
+}
37
+
38
+export {
39
+  getHookType,
40
+  internalRunHook
41
+}

+ 5
- 0
shared/models/plugins/hook-type.enum.ts View File

@@ -0,0 +1,5 @@
1
+export enum HookType {
2
+  STATIC = 1,
3
+  ACTION = 2,
4
+  FILTER = 3
5
+}

+ 34
- 0
shared/models/plugins/server-hook.model.ts View File

@@ -0,0 +1,34 @@
1
+export type ServerFilterHookName =
2
+  'filter:api.videos.list.params' |
3
+  'filter:api.videos.list.result' |
4
+  'filter:api.video.get.result' |
5
+
6
+  'filter:api.video.upload.accept.result' |
7
+  'filter:api.video-thread.create.accept.result' |
8
+  'filter:api.video-comment-reply.create.accept.result' |
9
+
10
+  'filter:api.video-thread-comments.list.params' |
11
+  'filter:api.video-thread-comments.list.result' |
12
+
13
+  'filter:api.video-threads.list.params' |
14
+  'filter:api.video-threads.list.result' |
15
+
16
+  'filter:video.auto-blacklist.result'
17
+
18
+export type ServerActionHookName =
19
+  'action:application.listening' |
20
+
21
+  'action:api.video.updated' |
22
+  'action:api.video.deleted' |
23
+  'action:api.video.uploaded' |
24
+  'action:api.video.viewed' |
25
+
26
+  'action:api.video-thread.created' |
27
+  'action:api.video-comment-reply.created' |
28
+  'action:api.video-comment.deleted'
29
+
30
+export type ServerHookName = ServerFilterHookName | ServerActionHookName
31
+
32
+export interface ServerHook {
33
+  runHook (hookName: ServerHookName, params?: any)
34
+}

+ 7
- 1
tslint.json View File

@@ -5,7 +5,13 @@
5 5
     "no-inferrable-types": true,
6 6
     "eofline": true,
7 7
     "indent": [true, "spaces"],
8
-    "ter-indent": [true, 2],
8
+    "ter-indent": [
9
+      true,
10
+      2,
11
+      {
12
+        "SwitchCase": 1
13
+      }
14
+    ],
9 15
     "max-line-length": [true, 140],
10 16
     "no-unused-variable": false, // Memory issues
11 17
     "no-floating-promises": false

Loading…
Cancel
Save