Add server hooks

This commit is contained in:
Chocobozzz 2019-07-18 14:28:37 +02:00 committed by Chocobozzz
parent 66e001c848
commit b4055e1c23
17 changed files with 422 additions and 112 deletions

View File

@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
import { AccountModel } from '../../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { Notifier } from '../../../lib/notifier' import { Notifier } from '../../../lib/notifier'
import { Hooks } from '../../../lib/plugins/hooks'
const auditLogger = auditLoggerFactory('comments') const auditLogger = auditLoggerFactory('comments')
const videoCommentRouter = express.Router() const videoCommentRouter = express.Router()
@ -76,7 +77,18 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
let resultList: ResultList<VideoCommentModel> let resultList: ResultList<VideoCommentModel>
if (video.commentsEnabled === true) { 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 { } else {
resultList = { resultList = {
total: 0, total: 0,
@ -94,7 +106,16 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
let resultList: ResultList<VideoCommentModel> let resultList: ResultList<VideoCommentModel>
if (video.commentsEnabled === true) { 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 { } else {
resultList = { resultList = {
total: 0, total: 0,
@ -122,6 +143,8 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
Notifier.Instance.notifyOnNewComment(comment) Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
Hooks.runAction('action:api.video-thread.created', { comment })
return res.json({ return res.json({
comment: comment.toFormattedJSON() comment: comment.toFormattedJSON()
}).end() }).end()
@ -144,6 +167,8 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
Notifier.Instance.notifyOnNewComment(comment) Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
Hooks.runAction('action:api.video-comment-reply.created', { comment })
return res.json({ comment: comment.toFormattedJSON() }).end() 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 }) await videoCommentInstance.destroy({ transaction: t })
}) })
auditLogger.delete( auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
getAuditIdFromRes(res),
new CommentAuditView(videoCommentInstance.toFormattedJSON())
)
logger.info('Video comment %d deleted.', videoCommentInstance.id) logger.info('Video comment %d deleted.', videoCommentInstance.id)
Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance })
return res.type('json').status(204).end() return res.type('json').status(204).end()
} }

View File

@ -62,6 +62,7 @@ import { sequelizeTypescript } from '../../../initializers/database'
import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding' import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding'
import { Hooks } from '../../../lib/plugins/hooks'
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router() 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) 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())) auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) 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 } return { videoCreated, videoWasAutoBlacklisted }
}) })
if (videoWasAutoBlacklisted) { if (videoWasAutoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated)
Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated) else Notifier.Instance.notifyOnNewVideo(videoCreated)
} else {
Notifier.Instance.notifyOnNewVideo(videoCreated)
}
if (video.state === VideoState.TO_TRANSCODE) { if (video.state === VideoState.TO_TRANSCODE) {
// Put uuid because we don't have id auto incremented for now // 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 }) await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
} }
Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
return res.json({ return res.json({
video: { video: {
id: videoCreated.id, id: videoCreated.id,
@ -421,6 +418,8 @@ async function updateVideo (req: express.Request, res: express.Response) {
if (wasUnlistedVideo || wasPrivateVideo) { if (wasUnlistedVideo || wasPrivateVideo) {
Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated) Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
} }
Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated })
} catch (err) { } catch (err) {
// Force fields we want to update // Force fields we want to update
// If the transaction is retried, sequelize will think the object has not changed // 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) { async function getVideo (req: express.Request, res: express.Response) {
// We need more attributes // We need more attributes
const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null 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()) { if (video.isOutdated()) {
JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } }) 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() const serverActor = await getServerActor()
await sendView(serverActor, videoInstance, undefined) await sendView(serverActor, videoInstance, undefined)
Hooks.runAction('action:api.video.viewed', { video: videoInstance, ip })
return res.status(204).end() 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) { async function listVideos (req: express.Request, res: express.Response) {
const resultList = await VideoModel.listForApi({ const apiOptions = await Hooks.wrapObject({
start: req.query.start, start: req.query.start,
count: req.query.count, count: req.query.count,
sort: req.query.sort, sort: req.query.sort,
@ -495,7 +500,12 @@ async function listVideos (req: express.Request, res: express.Response) {
filter: req.query.filter as VideoFilter, filter: req.query.filter as VideoFilter,
withFiles: false, withFiles: false,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined 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)) 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())) auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid) 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() return res.type('json').status(204).end()
} }

View File

@ -141,7 +141,7 @@ function root () {
const paths = [ __dirname, '..', '..' ] const paths = [ __dirname, '..', '..' ]
// We are under /dist directory // 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('..') paths.push('..')
} }

View File

@ -134,7 +134,7 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []):
}) })
if (sanitizeAndCheckVideoCommentObject(body) === false) { 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 const actorUrl = body.attributedTo

View File

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

64
server/lib/moderation.ts Normal file
View File

@ -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
}

View File

@ -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
}

View File

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

View File

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

View File

@ -9,6 +9,8 @@ import { UserModel } from '../../../models/account/user'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoCommentModel } from '../../../models/video/video-comment'
import { areValidationErrors } from '../utils' import { areValidationErrors } from '../utils'
import { Hooks } from '../../../lib/plugins/hooks'
import { isLocalVideoThreadAccepted, isLocalVideoCommentReplyAccepted, AcceptResult } from '../../../lib/moderation'
const listVideoCommentThreadsValidator = [ const listVideoCommentThreadsValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@ -48,6 +50,7 @@ const addVideoCommentThreadValidator = [
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return if (!await doesVideoExist(req.params.videoId, res)) return
if (!isVideoCommentsEnabled(res.locals.video, res)) return if (!isVideoCommentsEnabled(res.locals.video, res)) return
if (!await isVideoCommentAccepted(req, res, false)) return
return next() return next()
} }
@ -65,6 +68,7 @@ const addVideoCommentReplyValidator = [
if (!await doesVideoExist(req.params.videoId, res)) return if (!await doesVideoExist(req.params.videoId, res)) return
if (!isVideoCommentsEnabled(res.locals.video, res)) return if (!isVideoCommentsEnabled(res.locals.video, res)) return
if (!await doesVideoCommentExist(req.params.commentId, 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() return next()
} }
@ -193,3 +197,37 @@ function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCom
return true 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
}

View File

@ -33,7 +33,7 @@ import {
import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
import { authenticatePromiseIfNeeded } from '../../oauth' import { authenticate, authenticatePromiseIfNeeded } from '../../oauth'
import { areValidationErrors } from '../utils' import { areValidationErrors } from '../utils'
import { cleanUpReqFiles } from '../../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { VideoModel } from '../../../models/video/video' 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 { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
import { getServerActor } from '../../../helpers/utils' import { getServerActor } from '../../../helpers/utils'
import { CONFIG } from '../../../initializers/config' import { CONFIG } from '../../../initializers/config'
import { isLocalVideoAccepted } from '../../../lib/moderation'
import { Hooks } from '../../../lib/plugins/hooks'
const videosAddValidator = getCommonVideoEditAttributes().concat([ const videosAddValidator = getCommonVideoEditAttributes().concat([
body('videofile') body('videofile')
@ -62,14 +64,12 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInScheduleUpdate(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 const user = res.locals.oauth.token.User
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
const isAble = await user.isAbleToUploadVideo(videoFile) if (await user.isAbleToUploadVideo(videoFile) === false) {
if (isAble === false) {
res.status(403) res.status(403)
.json({ error: 'The user video quota is exceeded with this video.' }) .json({ error: 'The user video quota is exceeded with this video.' })
@ -88,7 +88,9 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
return cleanUpReqFiles(req) return cleanUpReqFiles(req)
} }
videoFile['duration'] = duration videoFile.duration = duration
if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
return next() return next()
} }
@ -434,3 +436,26 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
return false 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
}

View File

@ -293,7 +293,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query) 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 serverActor = await getServerActor()
const serverAccountId = serverActor.Account.id const serverAccountId = serverActor.Account.id
const userAccountId = user ? user.Account.id : undefined 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 serverActor = await getServerActor()
const serverAccountId = serverActor.Account.id const serverAccountId = serverActor.Account.id
const userAccountId = user ? user.Account.id : undefined const userAccountId = user ? user.Account.id : undefined

View File

@ -19,7 +19,17 @@ function compareSemVer (a: string, b: string) {
return segmentsA.length - segmentsB.length 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 { export {
randomInt, randomInt,
compareSemVer compareSemVer,
isPromise,
isCatchable
} }

View File

@ -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
}

View File

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

View File

@ -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)
}

View File

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