Browse Source

Add server hooks

Chocobozzz 4 years ago
parent
commit
b4055e1c23

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

@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
 import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
 import { AccountModel } from '../../../models/account/account'
 import { Notifier } from '../../../lib/notifier'
+import { Hooks } from '../../../lib/plugins/hooks'
 
 const auditLogger = auditLoggerFactory('comments')
 const videoCommentRouter = express.Router()
@@ -76,7 +77,18 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
   let resultList: ResultList<VideoCommentModel>
 
   if (video.commentsEnabled === true) {
-    resultList = await VideoCommentModel.listThreadsForApi(video.id, req.query.start, req.query.count, req.query.sort, user)
+    const apiOptions = await Hooks.wrapObject({
+      videoId: video.id,
+      start: req.query.start,
+      count: req.query.count,
+      sort: req.query.sort,
+      user: user
+    }, 'filter:api.video-threads.list.params')
+
+    resultList = await Hooks.wrapPromise(
+      VideoCommentModel.listThreadsForApi(apiOptions),
+      'filter:api.video-threads.list.result'
+    )
   } else {
     resultList = {
       total: 0,
@@ -94,7 +106,16 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
   let resultList: ResultList<VideoCommentModel>
 
   if (video.commentsEnabled === true) {
-    resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id, user)
+    const apiOptions = await Hooks.wrapObject({
+      videoId: video.id,
+      threadId: res.locals.videoCommentThread.id,
+      user: user
+    }, 'filter:api.video-thread-comments.list.params')
+
+    resultList = await Hooks.wrapPromise(
+      VideoCommentModel.listThreadCommentsForApi(apiOptions),
+      'filter:api.video-thread-comments.list.result'
+    )
   } else {
     resultList = {
       total: 0,
@@ -122,6 +143,8 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
   Notifier.Instance.notifyOnNewComment(comment)
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
+  Hooks.runAction('action:api.video-thread.created', { comment })
+
   return res.json({
     comment: comment.toFormattedJSON()
   }).end()
@@ -144,6 +167,8 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
   Notifier.Instance.notifyOnNewComment(comment)
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
+  Hooks.runAction('action:api.video-comment-reply.created', { comment })
+
   return res.json({ comment: comment.toFormattedJSON() }).end()
 }
 
@@ -154,11 +179,10 @@ async function removeVideoComment (req: express.Request, res: express.Response)
     await videoCommentInstance.destroy({ transaction: t })
   })
 
-  auditLogger.delete(
-    getAuditIdFromRes(res),
-    new CommentAuditView(videoCommentInstance.toFormattedJSON())
-  )
+  auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
   logger.info('Video comment %d deleted.', videoCommentInstance.id)
 
+  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance })
+
   return res.type('json').status(204).end()
 }

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

@@ -62,6 +62,7 @@ import { sequelizeTypescript } from '../../../initializers/database'
 import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
 import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
 import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding'
+import { Hooks } from '../../../lib/plugins/hooks'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -268,10 +269,7 @@ async function addVideo (req: express.Request, res: express.Response) {
     }
 
     const videoWasAutoBlacklisted = await autoBlacklistVideoIfNeeded(video, res.locals.oauth.token.User, t)
-
-    if (!videoWasAutoBlacklisted) {
-      await federateVideoIfNeeded(video, true, t)
-    }
+    if (!videoWasAutoBlacklisted) await federateVideoIfNeeded(video, true, t)
 
     auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
     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) {
     return { videoCreated, videoWasAutoBlacklisted }
   })
 
-  if (videoWasAutoBlacklisted) {
-    Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated)
-  } else {
-    Notifier.Instance.notifyOnNewVideo(videoCreated)
-  }
+  if (videoWasAutoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated)
+  else Notifier.Instance.notifyOnNewVideo(videoCreated)
 
   if (video.state === VideoState.TO_TRANSCODE) {
     // 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) {
     await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
   }
 
+  Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
+
   return res.json({
     video: {
       id: videoCreated.id,
@@ -421,6 +418,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
     if (wasUnlistedVideo || wasPrivateVideo) {
       Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
     }
+
+    Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated })
   } catch (err) {
     // Force fields we want to update
     // 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) {
 async function getVideo (req: express.Request, res: express.Response) {
   // We need more attributes
   const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
-  const video = await VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId)
+
+  const video = await Hooks.wrapPromise(
+    VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId),
+    'filter:api.video.get.result'
+  )
 
   if (video.isOutdated()) {
     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) {
   const serverActor = await getServerActor()
   await sendView(serverActor, videoInstance, undefined)
 
+  Hooks.runAction('action:api.video.viewed', { video: videoInstance, ip })
+
   return res.status(204).end()
 }
 
@@ -481,7 +486,7 @@ async function getVideoDescription (req: express.Request, res: express.Response)
 }
 
 async function listVideos (req: express.Request, res: express.Response) {
-  const resultList = await VideoModel.listForApi({
+  const apiOptions = await Hooks.wrapObject({
     start: req.query.start,
     count: req.query.count,
     sort: req.query.sort,
@@ -495,7 +500,12 @@ async function listVideos (req: express.Request, res: express.Response) {
     filter: req.query.filter as VideoFilter,
     withFiles: false,
     user: res.locals.oauth ? res.locals.oauth.token.User : undefined
-  })
+  }, 'filter:api.videos.list.params')
+
+  const resultList = await Hooks.wrapPromise(
+    VideoModel.listForApi(apiOptions),
+    'filter:api.videos.list.result'
+  )
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
@@ -510,5 +520,7 @@ async function removeVideo (req: express.Request, res: express.Response) {
   auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
   logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
 
+  Hooks.runAction('action:api.video.deleted', { video: videoInstance })
+
   return res.type('json').status(204).end()
 }

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

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

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

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

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

@@ -54,6 +54,8 @@ import { ThumbnailModel } from '../../models/video/thumbnail'
 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
 import { join } from 'path'
 import { FilteredModelAttributes } from '../../typings/sequelize'
+import { Hooks } from '../plugins/hooks'
+import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
 
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   // If the video is not private and is published, we federate it
@@ -236,72 +238,74 @@ async function updateVideoFromAP (options: {
   channel: VideoChannelModel,
   overrideTo?: string[]
 }) {
+  const { video, videoObject, account, channel, overrideTo } = options
+
   logger.debug('Updating remote video "%s".', options.videoObject.uuid)
 
   let videoFieldsSave: any
-  const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
-  const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
+  const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
+  const wasUnlistedVideo = video.privacy === VideoPrivacy.UNLISTED
 
   try {
     let thumbnailModel: ThumbnailModel
 
     try {
-      thumbnailModel = await createVideoMiniatureFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.MINIATURE)
+      thumbnailModel = await createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE)
     } catch (err) {
-      logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
+      logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })
     }
 
     await sequelizeTypescript.transaction(async t => {
       const sequelizeOptions = { transaction: t }
 
-      videoFieldsSave = options.video.toJSON()
+      videoFieldsSave = video.toJSON()
 
       // Check actor has the right to update the video
-      const videoChannel = options.video.VideoChannel
-      if (videoChannel.Account.id !== options.account.id) {
-        throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
+      const videoChannel = video.VideoChannel
+      if (videoChannel.Account.id !== account.id) {
+        throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
       }
 
-      const to = options.overrideTo ? options.overrideTo : options.videoObject.to
-      const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
-      options.video.set('name', videoData.name)
-      options.video.set('uuid', videoData.uuid)
-      options.video.set('url', videoData.url)
-      options.video.set('category', videoData.category)
-      options.video.set('licence', videoData.licence)
-      options.video.set('language', videoData.language)
-      options.video.set('description', videoData.description)
-      options.video.set('support', videoData.support)
-      options.video.set('nsfw', videoData.nsfw)
-      options.video.set('commentsEnabled', videoData.commentsEnabled)
-      options.video.set('downloadEnabled', videoData.downloadEnabled)
-      options.video.set('waitTranscoding', videoData.waitTranscoding)
-      options.video.set('state', videoData.state)
-      options.video.set('duration', videoData.duration)
-      options.video.set('createdAt', videoData.createdAt)
-      options.video.set('publishedAt', videoData.publishedAt)
-      options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
-      options.video.set('privacy', videoData.privacy)
-      options.video.set('channelId', videoData.channelId)
-      options.video.set('views', videoData.views)
-
-      await options.video.save(sequelizeOptions)
-
-      if (thumbnailModel) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t)
+      const to = overrideTo ? overrideTo : videoObject.to
+      const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
+      video.name = videoData.name
+      video.uuid = videoData.uuid
+      video.url = videoData.url
+      video.category = videoData.category
+      video.licence = videoData.licence
+      video.language = videoData.language
+      video.description = videoData.description
+      video.support = videoData.support
+      video.nsfw = videoData.nsfw
+      video.commentsEnabled = videoData.commentsEnabled
+      video.downloadEnabled = videoData.downloadEnabled
+      video.waitTranscoding = videoData.waitTranscoding
+      video.state = videoData.state
+      video.duration = videoData.duration
+      video.createdAt = videoData.createdAt
+      video.publishedAt = videoData.publishedAt
+      video.originallyPublishedAt = videoData.originallyPublishedAt
+      video.privacy = videoData.privacy
+      video.channelId = videoData.channelId
+      video.views = videoData.views
+
+      await video.save(sequelizeOptions)
+
+      if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
 
       // FIXME: use icon URL instead
-      const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
-      const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
-      await options.video.addAndSaveThumbnail(previewModel, t)
+      const previewUrl = buildRemoteBaseUrl(video, join(STATIC_PATHS.PREVIEWS, video.getPreview().filename))
+      const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
+      await video.addAndSaveThumbnail(previewModel, t)
 
       {
-        const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
+        const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
 
         // Remove video files that do not exist anymore
-        const destroyTasks = options.video.VideoFiles
-                                    .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
-                                    .map(f => f.destroy(sequelizeOptions))
+        const destroyTasks = video.VideoFiles
+                                  .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
+                                  .map(f => f.destroy(sequelizeOptions))
         await Promise.all(destroyTasks)
 
         // Update or add other one
@@ -310,21 +314,17 @@ async function updateVideoFromAP (options: {
             .then(([ file ]) => file)
         })
 
-        options.video.VideoFiles = await Promise.all(upsertTasks)
+        video.VideoFiles = await Promise.all(upsertTasks)
       }
 
       {
-        const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
-          options.video,
-          options.videoObject,
-          options.video.VideoFiles
-        )
+        const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(video, videoObject, video.VideoFiles)
         const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
 
         // Remove video files that do not exist anymore
-        const destroyTasks = options.video.VideoStreamingPlaylists
-                                    .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
-                                    .map(f => f.destroy(sequelizeOptions))
+        const destroyTasks = video.VideoStreamingPlaylists
+                                  .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
+                                  .map(f => f.destroy(sequelizeOptions))
         await Promise.all(destroyTasks)
 
         // Update or add other one
@@ -333,36 +333,36 @@ async function updateVideoFromAP (options: {
                                .then(([ streamingPlaylist ]) => streamingPlaylist)
         })
 
-        options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
+        video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
       }
 
       {
         // Update Tags
-        const tags = options.videoObject.tag.map(tag => tag.name)
+        const tags = videoObject.tag.map(tag => tag.name)
         const tagInstances = await TagModel.findOrCreateTags(tags, t)
-        await options.video.$set('Tags', tagInstances, sequelizeOptions)
+        await video.$set('Tags', tagInstances, sequelizeOptions)
       }
 
       {
         // Update captions
-        await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
+        await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
 
-        const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
-          return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
+        const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+          return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
         })
-        options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
+        video.VideoCaptions = await Promise.all(videoCaptionsPromises)
       }
     })
 
-    // Notify our users?
-    if (wasPrivateVideo || wasUnlistedVideo) {
-      Notifier.Instance.notifyOnNewVideo(options.video)
-    }
+    const autoBlacklisted = await autoBlacklistVideoIfNeeded(video, undefined, undefined)
+
+    if (autoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(video)
+    else if (!wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideo(video) // Notify our users?
 
-    logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
+    logger.info('Remote video with uuid %s updated', videoObject.uuid)
   } catch (err) {
-    if (options.video !== undefined && videoFieldsSave !== undefined) {
-      resetSequelizeInstance(options.video, videoFieldsSave)
+    if (video !== undefined && videoFieldsSave !== undefined) {
+      resetSequelizeInstance(video, videoFieldsSave)
     }
 
     // This is just a debug because we will retry the insert
@@ -379,7 +379,9 @@ async function refreshVideoIfNeeded (options: {
   if (!options.video.isOutdated()) return options.video
 
   // We need more attributes if the argument video was fetched with not enough joints
-  const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
+  const video = options.fetchedType === 'all'
+    ? options.video
+    : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
 
   try {
     const { response, videoObject } = await fetchRemoteVideo(video.url)

+ 64 - 0
server/lib/moderation.ts

@@ -0,0 +1,64 @@
+import { VideoModel } from '../models/video/video'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
+import { VideoCreate } from '../../shared/models/videos'
+import { UserModel } from '../models/account/user'
+import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
+import { ActivityCreate } from '../../shared/models/activitypub'
+import { ActorModel } from '../models/activitypub/actor'
+import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
+
+export type AcceptResult = {
+  accepted: boolean
+  errorMessage?: string
+}
+
+// Can be filtered by plugins
+function isLocalVideoAccepted (object: {
+  videoBody: VideoCreate,
+  videoFile: Express.Multer.File & { duration?: number },
+  user: UserModel
+}): AcceptResult {
+  return { accepted: true }
+}
+
+function isLocalVideoThreadAccepted (_object: {
+  commentBody: VideoCommentCreate,
+  video: VideoModel,
+  user: UserModel
+}): AcceptResult {
+  return { accepted: true }
+}
+
+function isLocalVideoCommentReplyAccepted (_object: {
+  commentBody: VideoCommentCreate,
+  parentComment: VideoCommentModel,
+  video: VideoModel,
+  user: UserModel
+}): AcceptResult {
+  return { accepted: true }
+}
+
+function isRemoteVideoAccepted (_object: {
+  activity: ActivityCreate,
+  videoAP: VideoTorrentObject,
+  byActor: ActorModel
+}): AcceptResult {
+  return { accepted: true }
+}
+
+function isRemoteVideoCommentAccepted (_object: {
+  activity: ActivityCreate,
+  commentAP: VideoCommentObject,
+  byActor: ActorModel
+}): AcceptResult {
+  return { accepted: true }
+}
+
+export {
+  isLocalVideoAccepted,
+  isLocalVideoThreadAccepted,
+  isRemoteVideoAccepted,
+  isRemoteVideoCommentAccepted,
+  isLocalVideoCommentReplyAccepted
+}

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

@@ -0,0 +1,26 @@
+import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models/plugins/server-hook.model'
+import { PluginManager } from './plugin-manager'
+import { logger } from '../../helpers/logger'
+import * as Bluebird from 'bluebird'
+
+// Helpers to run hooks
+const Hooks = {
+  wrapObject: <T, U extends ServerFilterHookName>(obj: T, hookName: U) => {
+    return PluginManager.Instance.runHook(hookName, obj) as Promise<T>
+  },
+
+  wrapPromise: async <T, U extends ServerFilterHookName>(fun: Promise<T> | Bluebird<T>, hookName: U) => {
+    const result = await fun
+
+    return PluginManager.Instance.runHook(hookName, result)
+  },
+
+  runAction: <T, U extends ServerActionHookName>(hookName: U, params?: T) => {
+    PluginManager.Instance.runHook(hookName, params)
+      .catch(err => logger.error('Fatal hook error.', { err }))
+  }
+}
+
+export {
+  Hooks
+}

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

@@ -14,6 +14,10 @@ import { RegisterSettingOptions } from '../../../shared/models/plugins/register-
 import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model'
 import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
 import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model'
+import { ServerHookName, ServerHook } from '../../../shared/models/plugins/server-hook.model'
+import { isCatchable, isPromise } from '../../../shared/core-utils/miscs/miscs'
+import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
+import { HookType } from '../../../shared/models/plugins/hook-type.enum'
 
 export interface RegisteredPlugin {
   npmName: string
@@ -42,7 +46,7 @@ export interface HookInformationValue {
   priority: number
 }
 
-export class PluginManager {
+export class PluginManager implements ServerHook {
 
   private static instance: PluginManager
 
@@ -95,25 +99,17 @@ export class PluginManager {
 
   // ###################### Hooks ######################
 
-  async runHook (hookName: string, param?: any) {
+  async runHook (hookName: ServerHookName, param?: any) {
     let result = param
 
     if (!this.hooks[hookName]) return result
 
-    const wait = hookName.startsWith('static:')
+    const hookType = getHookType(hookName)
 
     for (const hook of this.hooks[hookName]) {
-      try {
-        const p = hook.handler(param)
-
-        if (wait) {
-          result = await p
-        } else if (p.catch) {
-          p.catch(err => logger.warn('Hook %s of plugin %s thrown an error.', hookName, hook.pluginName, { err }))
-        }
-      } catch (err) {
+      result = await internalRunHook(hook.handler, hookType, param, err => {
         logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err })
-      }
+      })
     }
 
     return result

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

@@ -1,4 +1,4 @@
-import * as sequelize from 'sequelize'
+import { Transaction } from 'sequelize'
 import { CONFIG } from '../initializers/config'
 import { UserRight, VideoBlacklistType } from '../../shared/models'
 import { VideoBlacklistModel } from '../models/video/video-blacklist'
@@ -6,26 +6,39 @@ import { UserModel } from '../models/account/user'
 import { VideoModel } from '../models/video/video'
 import { logger } from '../helpers/logger'
 import { UserAdminFlag } from '../../shared/models/users/user-flag.model'
+import { Hooks } from './plugins/hooks'
 
-async function autoBlacklistVideoIfNeeded (video: VideoModel, user: UserModel, transaction: sequelize.Transaction) {
-  if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED) return false
+async function autoBlacklistVideoIfNeeded (video: VideoModel, user?: UserModel, transaction?: Transaction) {
+  const doAutoBlacklist = await Hooks.wrapPromise(
+    autoBlacklistNeeded({ video, user }),
+    'filter:video.auto-blacklist.result'
+  )
 
-  if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST)) return false
+  if (!doAutoBlacklist) return false
 
-  const sequelizeOptions = { transaction }
   const videoBlacklistToCreate = {
     videoId: video.id,
     unfederated: true,
     reason: 'Auto-blacklisted. Moderator review required.',
     type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED
   }
-  await VideoBlacklistModel.create(videoBlacklistToCreate, sequelizeOptions)
+  await VideoBlacklistModel.create(videoBlacklistToCreate, { transaction })
 
   logger.info('Video %s auto-blacklisted.', video.uuid)
 
   return true
 }
 
+async function autoBlacklistNeeded (parameters: { video: VideoModel, user?: UserModel }) {
+  const { user } = parameters
+
+  if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
+
+  if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST)) return false
+
+  return true
+}
+
 // ---------------------------------------------------------------------------
 
 export {

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

@@ -9,6 +9,8 @@ import { UserModel } from '../../../models/account/user'
 import { VideoModel } from '../../../models/video/video'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { areValidationErrors } from '../utils'
+import { Hooks } from '../../../lib/plugins/hooks'
+import { isLocalVideoThreadAccepted, isLocalVideoCommentReplyAccepted, AcceptResult } from '../../../lib/moderation'
 
 const listVideoCommentThreadsValidator = [
   param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -48,6 +50,7 @@ const addVideoCommentThreadValidator = [
     if (areValidationErrors(req, res)) return
     if (!await doesVideoExist(req.params.videoId, res)) return
     if (!isVideoCommentsEnabled(res.locals.video, res)) return
+    if (!await isVideoCommentAccepted(req, res, false)) return
 
     return next()
   }
@@ -65,6 +68,7 @@ const addVideoCommentReplyValidator = [
     if (!await doesVideoExist(req.params.videoId, res)) return
     if (!isVideoCommentsEnabled(res.locals.video, res)) return
     if (!await doesVideoCommentExist(req.params.commentId, res.locals.video, res)) return
+    if (!await isVideoCommentAccepted(req, res, true)) return
 
     return next()
   }
@@ -193,3 +197,37 @@ function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCom
 
   return true
 }
+
+async function isVideoCommentAccepted (req: express.Request, res: express.Response, isReply: boolean) {
+  const acceptParameters = {
+    video: res.locals.video,
+    commentBody: req.body,
+    user: res.locals.oauth.token.User
+  }
+
+  let acceptedResult: AcceptResult
+
+  if (isReply) {
+    const acceptReplyParameters = Object.assign(acceptParameters, { parentComment: res.locals.videoComment })
+
+    acceptedResult = await Hooks.wrapObject(
+      isLocalVideoCommentReplyAccepted(acceptReplyParameters),
+      'filter:api.video-comment-reply.create.accept.result'
+    )
+  } else {
+    acceptedResult = await Hooks.wrapObject(
+      isLocalVideoThreadAccepted(acceptParameters),
+      'filter:api.video-thread.create.accept.result'
+    )
+  }
+
+  if (!acceptedResult || acceptedResult.accepted !== true) {
+    logger.info('Refused local comment.', { acceptedResult, acceptParameters })
+    res.status(403)
+              .json({ error: acceptedResult.errorMessage || 'Refused local comment' })
+
+    return false
+  }
+
+  return true
+}

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

@@ -33,7 +33,7 @@ import {
 import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
 import { logger } from '../../../helpers/logger'
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
-import { authenticatePromiseIfNeeded } from '../../oauth'
+import { authenticate, authenticatePromiseIfNeeded } from '../../oauth'
 import { areValidationErrors } from '../utils'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { VideoModel } from '../../../models/video/video'
@@ -44,6 +44,8 @@ import { VideoFetchType } from '../../../helpers/video'
 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
 import { getServerActor } from '../../../helpers/utils'
 import { CONFIG } from '../../../initializers/config'
+import { isLocalVideoAccepted } from '../../../lib/moderation'
+import { Hooks } from '../../../lib/plugins/hooks'
 
 const videosAddValidator = getCommonVideoEditAttributes().concat([
   body('videofile')
@@ -62,14 +64,12 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
     if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
 
-    const videoFile: Express.Multer.File = req.files['videofile'][0]
+    const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
     const user = res.locals.oauth.token.User
 
     if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
 
-    const isAble = await user.isAbleToUploadVideo(videoFile)
-
-    if (isAble === false) {
+    if (await user.isAbleToUploadVideo(videoFile) === false) {
       res.status(403)
          .json({ error: 'The user video quota is exceeded with this video.' })
 
@@ -88,7 +88,9 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
       return cleanUpReqFiles(req)
     }
 
-    videoFile['duration'] = duration
+    videoFile.duration = duration
+
+    if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
 
     return next()
   }
@@ -434,3 +436,26 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
 
   return false
 }
+
+async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
+  // Check we accept this video
+  const acceptParameters = {
+    videoBody: req.body,
+    videoFile,
+    user: res.locals.oauth.token.User
+  }
+  const acceptedResult = await Hooks.wrapObject(
+    isLocalVideoAccepted(acceptParameters),
+    'filter:api.video.upload.accept.result'
+  )
+
+  if (!acceptedResult || acceptedResult.accepted !== true) {
+    logger.info('Refused local video.', { acceptedResult, acceptParameters })
+    res.status(403)
+       .json({ error: acceptedResult.errorMessage || 'Refused local video' })
+
+    return false
+  }
+
+  return true
+}

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

@@ -293,7 +293,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
   }
 
-  static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
+  static async listThreadsForApi (parameters: {
+    videoId: number,
+    start: number,
+    count: number,
+    sort: string,
+    user?: UserModel
+  }) {
+    const { videoId, start, count, sort, user } = parameters
+
     const serverActor = await getServerActor()
     const serverAccountId = serverActor.Account.id
     const userAccountId = user ? user.Account.id : undefined
@@ -328,7 +336,13 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       })
   }
 
-  static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
+  static async listThreadCommentsForApi (parameters: {
+    videoId: number,
+    threadId: number,
+    user?: UserModel
+  }) {
+    const { videoId, threadId, user } = parameters
+
     const serverActor = await getServerActor()
     const serverAccountId = serverActor.Account.id
     const userAccountId = user ? user.Account.id : undefined

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

@@ -19,7 +19,17 @@ function compareSemVer (a: string, b: string) {
   return segmentsA.length - segmentsB.length
 }
 
+function isPromise (value: any) {
+  return value && typeof value.then === 'function'
+}
+
+function isCatchable (value: any) {
+  return value && typeof value.catch === 'function'
+}
+
 export {
   randomInt,
-  compareSemVer
+  compareSemVer,
+  isPromise,
+  isCatchable
 }

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

@@ -0,0 +1,41 @@
+import { HookType } from '../../models/plugins/hook-type.enum'
+import { isCatchable, isPromise } from '../miscs/miscs'
+
+function getHookType (hookName: string) {
+  if (hookName.startsWith('filter:')) return HookType.FILTER
+  if (hookName.startsWith('action:')) return HookType.ACTION
+
+  return HookType.STATIC
+}
+
+async function internalRunHook (handler: Function, hookType: HookType, param: any, onError: (err: Error) => void) {
+  let result = param
+
+  try {
+    const p = handler(result)
+
+    switch (hookType) {
+      case HookType.FILTER:
+        if (isPromise(p)) result = await p
+        else result = p
+        break
+
+      case HookType.STATIC:
+        if (isPromise(p)) await p
+        break
+
+      case HookType.ACTION:
+        if (isCatchable(p)) p.catch(err => onError(err))
+        break
+    }
+  } catch (err) {
+    onError(err)
+  }
+
+  return result
+}
+
+export {
+  getHookType,
+  internalRunHook
+}

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

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

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

@@ -0,0 +1,34 @@
+export type ServerFilterHookName =
+  'filter:api.videos.list.params' |
+  'filter:api.videos.list.result' |
+  'filter:api.video.get.result' |
+
+  'filter:api.video.upload.accept.result' |
+  'filter:api.video-thread.create.accept.result' |
+  'filter:api.video-comment-reply.create.accept.result' |
+
+  'filter:api.video-thread-comments.list.params' |
+  'filter:api.video-thread-comments.list.result' |
+
+  'filter:api.video-threads.list.params' |
+  'filter:api.video-threads.list.result' |
+
+  'filter:video.auto-blacklist.result'
+
+export type ServerActionHookName =
+  'action:application.listening' |
+
+  'action:api.video.updated' |
+  'action:api.video.deleted' |
+  'action:api.video.uploaded' |
+  'action:api.video.viewed' |
+
+  'action:api.video-thread.created' |
+  'action:api.video-comment-reply.created' |
+  'action:api.video-comment.deleted'
+
+export type ServerHookName = ServerFilterHookName | ServerActionHookName
+
+export interface ServerHook {
+  runHook (hookName: ServerHookName, params?: any)
+}

+ 7 - 1
tslint.json

@@ -5,7 +5,13 @@
     "no-inferrable-types": true,
     "eofline": true,
     "indent": [true, "spaces"],
-    "ter-indent": [true, 2],
+    "ter-indent": [
+      true,
+      2,
+      {
+        "SwitchCase": 1
+      }
+    ],
     "max-line-length": [true, 140],
     "no-unused-variable": false, // Memory issues
     "no-floating-promises": false