diff options
-rw-r--r-- | server/controllers/api/videos/comment.ts | 36 | ||||
-rw-r--r-- | server/controllers/api/videos/index.ts | 36 | ||||
-rw-r--r-- | server/helpers/core-utils.ts | 2 | ||||
-rw-r--r-- | server/lib/activitypub/video-comments.ts | 2 | ||||
-rw-r--r-- | server/lib/activitypub/videos.ts | 132 | ||||
-rw-r--r-- | server/lib/moderation.ts | 64 | ||||
-rw-r--r-- | server/lib/plugins/hooks.ts | 26 | ||||
-rw-r--r-- | server/lib/plugins/plugin-manager.ts | 22 | ||||
-rw-r--r-- | server/lib/video-blacklist.ts | 25 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-comments.ts | 38 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 37 | ||||
-rw-r--r-- | server/models/video/video-comment.ts | 18 | ||||
-rw-r--r-- | shared/core-utils/miscs/miscs.ts | 12 | ||||
-rw-r--r-- | shared/core-utils/plugins/hooks.ts | 41 | ||||
-rw-r--r-- | shared/models/plugins/hook-type.enum.ts | 5 | ||||
-rw-r--r-- | shared/models/plugins/server-hook.model.ts | 34 | ||||
-rw-r--r-- | tslint.json | 8 |
17 files changed, 424 insertions, 114 deletions
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index 176ee8bd4..a95392543 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts | |||
@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment' | |||
26 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' | 26 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' |
27 | import { AccountModel } from '../../../models/account/account' | 27 | import { AccountModel } from '../../../models/account/account' |
28 | import { Notifier } from '../../../lib/notifier' | 28 | import { Notifier } from '../../../lib/notifier' |
29 | import { Hooks } from '../../../lib/plugins/hooks' | ||
29 | 30 | ||
30 | const auditLogger = auditLoggerFactory('comments') | 31 | const auditLogger = auditLoggerFactory('comments') |
31 | const videoCommentRouter = express.Router() | 32 | const videoCommentRouter = express.Router() |
@@ -76,7 +77,18 @@ async function listVideoThreads (req: express.Request, res: express.Response) { | |||
76 | let resultList: ResultList<VideoCommentModel> | 77 | let resultList: ResultList<VideoCommentModel> |
77 | 78 | ||
78 | if (video.commentsEnabled === true) { | 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 | } else { | 92 | } else { |
81 | resultList = { | 93 | resultList = { |
82 | total: 0, | 94 | total: 0, |
@@ -94,7 +106,16 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo | |||
94 | let resultList: ResultList<VideoCommentModel> | 106 | let resultList: ResultList<VideoCommentModel> |
95 | 107 | ||
96 | if (video.commentsEnabled === true) { | 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 | } else { | 119 | } else { |
99 | resultList = { | 120 | resultList = { |
100 | total: 0, | 121 | total: 0, |
@@ -122,6 +143,8 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons | |||
122 | Notifier.Instance.notifyOnNewComment(comment) | 143 | Notifier.Instance.notifyOnNewComment(comment) |
123 | auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) | 144 | auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) |
124 | 145 | ||
146 | Hooks.runAction('action:api.video-thread.created', { comment }) | ||
147 | |||
125 | return res.json({ | 148 | return res.json({ |
126 | comment: comment.toFormattedJSON() | 149 | comment: comment.toFormattedJSON() |
127 | }).end() | 150 | }).end() |
@@ -144,6 +167,8 @@ async function addVideoCommentReply (req: express.Request, res: express.Response | |||
144 | Notifier.Instance.notifyOnNewComment(comment) | 167 | Notifier.Instance.notifyOnNewComment(comment) |
145 | auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) | 168 | auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) |
146 | 169 | ||
170 | Hooks.runAction('action:api.video-comment-reply.created', { comment }) | ||
171 | |||
147 | return res.json({ comment: comment.toFormattedJSON() }).end() | 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 | await videoCommentInstance.destroy({ transaction: t }) | 179 | await videoCommentInstance.destroy({ transaction: t }) |
155 | }) | 180 | }) |
156 | 181 | ||
157 | auditLogger.delete( | 182 | auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON())) |
158 | getAuditIdFromRes(res), | ||
159 | new CommentAuditView(videoCommentInstance.toFormattedJSON()) | ||
160 | ) | ||
161 | logger.info('Video comment %d deleted.', videoCommentInstance.id) | 183 | logger.info('Video comment %d deleted.', videoCommentInstance.id) |
162 | 184 | ||
185 | Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstance }) | ||
186 | |||
163 | return res.type('json').status(204).end() | 187 | return res.type('json').status(204).end() |
164 | } | 188 | } |
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 5ebd8fbc4..a3b1dde29 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -62,6 +62,7 @@ import { sequelizeTypescript } from '../../../initializers/database' | |||
62 | import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' | 62 | import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' |
63 | import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' | 63 | import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' |
64 | import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding' | 64 | import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding' |
65 | import { Hooks } from '../../../lib/plugins/hooks' | ||
65 | 66 | ||
66 | const auditLogger = auditLoggerFactory('videos') | 67 | const auditLogger = auditLoggerFactory('videos') |
67 | const videosRouter = express.Router() | 68 | const videosRouter = express.Router() |
@@ -268,10 +269,7 @@ async function addVideo (req: express.Request, res: express.Response) { | |||
268 | } | 269 | } |
269 | 270 | ||
270 | const videoWasAutoBlacklisted = await autoBlacklistVideoIfNeeded(video, res.locals.oauth.token.User, t) | 271 | const videoWasAutoBlacklisted = await autoBlacklistVideoIfNeeded(video, res.locals.oauth.token.User, t) |
271 | 272 | if (!videoWasAutoBlacklisted) await federateVideoIfNeeded(video, true, t) | |
272 | if (!videoWasAutoBlacklisted) { | ||
273 | await federateVideoIfNeeded(video, true, t) | ||
274 | } | ||
275 | 273 | ||
276 | auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) | 274 | auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) |
277 | logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) | 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 | return { videoCreated, videoWasAutoBlacklisted } | 277 | return { videoCreated, videoWasAutoBlacklisted } |
280 | }) | 278 | }) |
281 | 279 | ||
282 | if (videoWasAutoBlacklisted) { | 280 | if (videoWasAutoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated) |
283 | Notifier.Instance.notifyOnVideoAutoBlacklist(videoCreated) | 281 | else Notifier.Instance.notifyOnNewVideo(videoCreated) |
284 | } else { | ||
285 | Notifier.Instance.notifyOnNewVideo(videoCreated) | ||
286 | } | ||
287 | 282 | ||
288 | if (video.state === VideoState.TO_TRANSCODE) { | 283 | if (video.state === VideoState.TO_TRANSCODE) { |
289 | // Put uuid because we don't have id auto incremented for now | 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 | await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) | 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 | return res.json({ | 307 | return res.json({ |
311 | video: { | 308 | video: { |
312 | id: videoCreated.id, | 309 | id: videoCreated.id, |
@@ -421,6 +418,8 @@ async function updateVideo (req: express.Request, res: express.Response) { | |||
421 | if (wasUnlistedVideo || wasPrivateVideo) { | 418 | if (wasUnlistedVideo || wasPrivateVideo) { |
422 | Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated) | 419 | Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated) |
423 | } | 420 | } |
421 | |||
422 | Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated }) | ||
424 | } catch (err) { | 423 | } catch (err) { |
425 | // Force fields we want to update | 424 | // Force fields we want to update |
426 | // If the transaction is retried, sequelize will think the object has not changed | 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 | async function getVideo (req: express.Request, res: express.Response) { | 435 | async function getVideo (req: express.Request, res: express.Response) { |
437 | // We need more attributes | 436 | // We need more attributes |
438 | const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null | 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 | if (video.isOutdated()) { | 444 | if (video.isOutdated()) { |
442 | JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } }) | 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 | const serverActor = await getServerActor() | 467 | const serverActor = await getServerActor() |
465 | await sendView(serverActor, videoInstance, undefined) | 468 | await sendView(serverActor, videoInstance, undefined) |
466 | 469 | ||
470 | Hooks.runAction('action:api.video.viewed', { video: videoInstance, ip }) | ||
471 | |||
467 | return res.status(204).end() | 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 | async function listVideos (req: express.Request, res: express.Response) { | 488 | async function listVideos (req: express.Request, res: express.Response) { |
484 | const resultList = await VideoModel.listForApi({ | 489 | const apiOptions = await Hooks.wrapObject({ |
485 | start: req.query.start, | 490 | start: req.query.start, |
486 | count: req.query.count, | 491 | count: req.query.count, |
487 | sort: req.query.sort, | 492 | sort: req.query.sort, |
@@ -495,7 +500,12 @@ async function listVideos (req: express.Request, res: express.Response) { | |||
495 | filter: req.query.filter as VideoFilter, | 500 | filter: req.query.filter as VideoFilter, |
496 | withFiles: false, | 501 | withFiles: false, |
497 | user: res.locals.oauth ? res.locals.oauth.token.User : undefined | 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 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 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 | auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON())) | 520 | auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON())) |
511 | logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid) | 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 | return res.type('json').status(204).end() | 525 | return res.type('json').status(204).end() |
514 | } | 526 | } |
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts index 9771351e1..38b6f63f8 100644 --- a/server/helpers/core-utils.ts +++ b/server/helpers/core-utils.ts | |||
@@ -141,7 +141,7 @@ function root () { | |||
141 | const paths = [ __dirname, '..', '..' ] | 141 | const paths = [ __dirname, '..', '..' ] |
142 | 142 | ||
143 | // We are under /dist directory | 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 | paths.push('..') | 145 | paths.push('..') |
146 | } | 146 | } |
147 | 147 | ||
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts index c3fc6b462..2f26ddefd 100644 --- a/server/lib/activitypub/video-comments.ts +++ b/server/lib/activitypub/video-comments.ts | |||
@@ -134,7 +134,7 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []): | |||
134 | }) | 134 | }) |
135 | 135 | ||
136 | if (sanitizeAndCheckVideoCommentObject(body) === false) { | 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 | const actorUrl = body.attributedTo | 140 | const actorUrl = body.attributedTo |
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 4f26cb6be..dade6b55f 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts | |||
@@ -54,6 +54,8 @@ import { ThumbnailModel } from '../../models/video/thumbnail' | |||
54 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' | 54 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' |
55 | import { join } from 'path' | 55 | import { join } from 'path' |
56 | import { FilteredModelAttributes } from '../../typings/sequelize' | 56 | import { FilteredModelAttributes } from '../../typings/sequelize' |
57 | import { Hooks } from '../plugins/hooks' | ||
58 | import { autoBlacklistVideoIfNeeded } from '../video-blacklist' | ||
57 | 59 | ||
58 | async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { | 60 | async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { |
59 | // If the video is not private and is published, we federate it | 61 | // If the video is not private and is published, we federate it |
@@ -236,72 +238,74 @@ async function updateVideoFromAP (options: { | |||
236 | channel: VideoChannelModel, | 238 | channel: VideoChannelModel, |
237 | overrideTo?: string[] | 239 | overrideTo?: string[] |
238 | }) { | 240 | }) { |
241 | const { video, videoObject, account, channel, overrideTo } = options | ||
242 | |||
239 | logger.debug('Updating remote video "%s".', options.videoObject.uuid) | 243 | logger.debug('Updating remote video "%s".', options.videoObject.uuid) |
240 | 244 | ||
241 | let videoFieldsSave: any | 245 | let videoFieldsSave: any |
242 | const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE | 246 | const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE |
243 | const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED | 247 | const wasUnlistedVideo = video.privacy === VideoPrivacy.UNLISTED |
244 | 248 | ||
245 | try { | 249 | try { |
246 | let thumbnailModel: ThumbnailModel | 250 | let thumbnailModel: ThumbnailModel |
247 | 251 | ||
248 | try { | 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 | } catch (err) { | 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 | await sequelizeTypescript.transaction(async t => { | 258 | await sequelizeTypescript.transaction(async t => { |
255 | const sequelizeOptions = { transaction: t } | 259 | const sequelizeOptions = { transaction: t } |
256 | 260 | ||
257 | videoFieldsSave = options.video.toJSON() | 261 | videoFieldsSave = video.toJSON() |
258 | 262 | ||
259 | // Check actor has the right to update the video | 263 | // Check actor has the right to update the video |
260 | const videoChannel = options.video.VideoChannel | 264 | const videoChannel = video.VideoChannel |
261 | if (videoChannel.Account.id !== options.account.id) { | 265 | if (videoChannel.Account.id !== account.id) { |
262 | throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url) | 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 | 269 | const to = overrideTo ? overrideTo : videoObject.to |
266 | const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to) | 270 | const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to) |
267 | options.video.set('name', videoData.name) | 271 | video.name = videoData.name |
268 | options.video.set('uuid', videoData.uuid) | 272 | video.uuid = videoData.uuid |
269 | options.video.set('url', videoData.url) | 273 | video.url = videoData.url |
270 | options.video.set('category', videoData.category) | 274 | video.category = videoData.category |
271 | options.video.set('licence', videoData.licence) | 275 | video.licence = videoData.licence |
272 | options.video.set('language', videoData.language) | 276 | video.language = videoData.language |
273 | options.video.set('description', videoData.description) | 277 | video.description = videoData.description |
274 | options.video.set('support', videoData.support) | 278 | video.support = videoData.support |
275 | options.video.set('nsfw', videoData.nsfw) | 279 | video.nsfw = videoData.nsfw |
276 | options.video.set('commentsEnabled', videoData.commentsEnabled) | 280 | video.commentsEnabled = videoData.commentsEnabled |
277 | options.video.set('downloadEnabled', videoData.downloadEnabled) | 281 | video.downloadEnabled = videoData.downloadEnabled |
278 | options.video.set('waitTranscoding', videoData.waitTranscoding) | 282 | video.waitTranscoding = videoData.waitTranscoding |
279 | options.video.set('state', videoData.state) | 283 | video.state = videoData.state |
280 | options.video.set('duration', videoData.duration) | 284 | video.duration = videoData.duration |
281 | options.video.set('createdAt', videoData.createdAt) | 285 | video.createdAt = videoData.createdAt |
282 | options.video.set('publishedAt', videoData.publishedAt) | 286 | video.publishedAt = videoData.publishedAt |
283 | options.video.set('originallyPublishedAt', videoData.originallyPublishedAt) | 287 | video.originallyPublishedAt = videoData.originallyPublishedAt |
284 | options.video.set('privacy', videoData.privacy) | 288 | video.privacy = videoData.privacy |
285 | options.video.set('channelId', videoData.channelId) | 289 | video.channelId = videoData.channelId |
286 | options.video.set('views', videoData.views) | 290 | video.views = videoData.views |
287 | 291 | ||
288 | await options.video.save(sequelizeOptions) | 292 | await video.save(sequelizeOptions) |
289 | 293 | ||
290 | if (thumbnailModel) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t) | 294 | if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) |
291 | 295 | ||
292 | // FIXME: use icon URL instead | 296 | // FIXME: use icon URL instead |
293 | const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename)) | 297 | const previewUrl = buildRemoteBaseUrl(video, join(STATIC_PATHS.PREVIEWS, video.getPreview().filename)) |
294 | const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE) | 298 | const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE) |
295 | await options.video.addAndSaveThumbnail(previewModel, t) | 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 | const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) | 303 | const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) |
300 | 304 | ||
301 | // Remove video files that do not exist anymore | 305 | // Remove video files that do not exist anymore |
302 | const destroyTasks = options.video.VideoFiles | 306 | const destroyTasks = video.VideoFiles |
303 | .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f))) | 307 | .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f))) |
304 | .map(f => f.destroy(sequelizeOptions)) | 308 | .map(f => f.destroy(sequelizeOptions)) |
305 | await Promise.all(destroyTasks) | 309 | await Promise.all(destroyTasks) |
306 | 310 | ||
307 | // Update or add other one | 311 | // Update or add other one |
@@ -310,21 +314,17 @@ async function updateVideoFromAP (options: { | |||
310 | .then(([ file ]) => file) | 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( | 321 | const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(video, videoObject, video.VideoFiles) |
318 | options.video, | ||
319 | options.videoObject, | ||
320 | options.video.VideoFiles | ||
321 | ) | ||
322 | const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) | 322 | const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) |
323 | 323 | ||
324 | // Remove video files that do not exist anymore | 324 | // Remove video files that do not exist anymore |
325 | const destroyTasks = options.video.VideoStreamingPlaylists | 325 | const destroyTasks = video.VideoStreamingPlaylists |
326 | .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f))) | 326 | .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f))) |
327 | .map(f => f.destroy(sequelizeOptions)) | 327 | .map(f => f.destroy(sequelizeOptions)) |
328 | await Promise.all(destroyTasks) | 328 | await Promise.all(destroyTasks) |
329 | 329 | ||
330 | // Update or add other one | 330 | // Update or add other one |
@@ -333,36 +333,36 @@ async function updateVideoFromAP (options: { | |||
333 | .then(([ streamingPlaylist ]) => streamingPlaylist) | 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 | // Update Tags | 340 | // Update Tags |
341 | const tags = options.videoObject.tag.map(tag => tag.name) | 341 | const tags = videoObject.tag.map(tag => tag.name) |
342 | const tagInstances = await TagModel.findOrCreateTags(tags, t) | 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 | // Update captions | 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 => { | 350 | const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { |
351 | return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t) | 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? | 357 | const autoBlacklisted = await autoBlacklistVideoIfNeeded(video, undefined, undefined) |
358 | if (wasPrivateVideo || wasUnlistedVideo) { | 358 | |
359 | Notifier.Instance.notifyOnNewVideo(options.video) | 359 | if (autoBlacklisted) Notifier.Instance.notifyOnVideoAutoBlacklist(video) |
360 | } | 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 | } catch (err) { | 363 | } catch (err) { |
364 | if (options.video !== undefined && videoFieldsSave !== undefined) { | 364 | if (video !== undefined && videoFieldsSave !== undefined) { |
365 | resetSequelizeInstance(options.video, videoFieldsSave) | 365 | resetSequelizeInstance(video, videoFieldsSave) |
366 | } | 366 | } |
367 | 367 | ||
368 | // This is just a debug because we will retry the insert | 368 | // This is just a debug because we will retry the insert |
@@ -379,7 +379,9 @@ async function refreshVideoIfNeeded (options: { | |||
379 | if (!options.video.isOutdated()) return options.video | 379 | if (!options.video.isOutdated()) return options.video |
380 | 380 | ||
381 | // We need more attributes if the argument video was fetched with not enough joints | 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 | try { | 386 | try { |
385 | const { response, videoObject } = await fetchRemoteVideo(video.url) | 387 | const { response, videoObject } = await fetchRemoteVideo(video.url) |
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts new file mode 100644 index 000000000..b609f4585 --- /dev/null +++ b/server/lib/moderation.ts | |||
@@ -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 | } | ||
diff --git a/server/lib/plugins/hooks.ts b/server/lib/plugins/hooks.ts new file mode 100644 index 000000000..7bb907e6a --- /dev/null +++ b/server/lib/plugins/hooks.ts | |||
@@ -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 | } | ||
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts index 570b56193..85ee3decb 100644 --- a/server/lib/plugins/plugin-manager.ts +++ b/server/lib/plugins/plugin-manager.ts | |||
@@ -14,6 +14,10 @@ import { RegisterSettingOptions } from '../../../shared/models/plugins/register- | |||
14 | import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model' | 14 | import { RegisterHookOptions } from '../../../shared/models/plugins/register-hook.model' |
15 | import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' | 15 | import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model' |
16 | import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model' | 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 | export interface RegisteredPlugin { | 22 | export interface RegisteredPlugin { |
19 | npmName: string | 23 | npmName: string |
@@ -42,7 +46,7 @@ export interface HookInformationValue { | |||
42 | priority: number | 46 | priority: number |
43 | } | 47 | } |
44 | 48 | ||
45 | export class PluginManager { | 49 | export class PluginManager implements ServerHook { |
46 | 50 | ||
47 | private static instance: PluginManager | 51 | private static instance: PluginManager |
48 | 52 | ||
@@ -95,25 +99,17 @@ export class PluginManager { | |||
95 | 99 | ||
96 | // ###################### Hooks ###################### | 100 | // ###################### Hooks ###################### |
97 | 101 | ||
98 | async runHook (hookName: string, param?: any) { | 102 | async runHook (hookName: ServerHookName, param?: any) { |
99 | let result = param | 103 | let result = param |
100 | 104 | ||
101 | if (!this.hooks[hookName]) return result | 105 | if (!this.hooks[hookName]) return result |
102 | 106 | ||
103 | const wait = hookName.startsWith('static:') | 107 | const hookType = getHookType(hookName) |
104 | 108 | ||
105 | for (const hook of this.hooks[hookName]) { | 109 | for (const hook of this.hooks[hookName]) { |
106 | try { | 110 | result = await internalRunHook(hook.handler, hookType, param, err => { |
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) { | ||
115 | logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) | 111 | logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) |
116 | } | 112 | }) |
117 | } | 113 | } |
118 | 114 | ||
119 | return result | 115 | return result |
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts index 985b89e31..32b1a28fa 100644 --- a/server/lib/video-blacklist.ts +++ b/server/lib/video-blacklist.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import * as sequelize from 'sequelize' | 1 | import { Transaction } from 'sequelize' |
2 | import { CONFIG } from '../initializers/config' | 2 | import { CONFIG } from '../initializers/config' |
3 | import { UserRight, VideoBlacklistType } from '../../shared/models' | 3 | import { UserRight, VideoBlacklistType } from '../../shared/models' |
4 | import { VideoBlacklistModel } from '../models/video/video-blacklist' | 4 | import { VideoBlacklistModel } from '../models/video/video-blacklist' |
@@ -6,26 +6,39 @@ import { UserModel } from '../models/account/user' | |||
6 | import { VideoModel } from '../models/video/video' | 6 | import { VideoModel } from '../models/video/video' |
7 | import { logger } from '../helpers/logger' | 7 | import { logger } from '../helpers/logger' |
8 | import { UserAdminFlag } from '../../shared/models/users/user-flag.model' | 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 | async function autoBlacklistVideoIfNeeded (video: VideoModel, user?: UserModel, transaction?: Transaction) { |
11 | if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED) return false | 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 | const videoBlacklistToCreate = { | 19 | const videoBlacklistToCreate = { |
17 | videoId: video.id, | 20 | videoId: video.id, |
18 | unfederated: true, | 21 | unfederated: true, |
19 | reason: 'Auto-blacklisted. Moderator review required.', | 22 | reason: 'Auto-blacklisted. Moderator review required.', |
20 | type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED | 23 | type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED |
21 | } | 24 | } |
22 | await VideoBlacklistModel.create(videoBlacklistToCreate, sequelizeOptions) | 25 | await VideoBlacklistModel.create(videoBlacklistToCreate, { transaction }) |
23 | 26 | ||
24 | logger.info('Video %s auto-blacklisted.', video.uuid) | 27 | logger.info('Video %s auto-blacklisted.', video.uuid) |
25 | 28 | ||
26 | return true | 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 | export { | 44 | export { |
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index ffde208b7..9c1bfaeaa 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts | |||
@@ -9,6 +9,8 @@ import { UserModel } from '../../../models/account/user' | |||
9 | import { VideoModel } from '../../../models/video/video' | 9 | import { VideoModel } from '../../../models/video/video' |
10 | import { VideoCommentModel } from '../../../models/video/video-comment' | 10 | import { VideoCommentModel } from '../../../models/video/video-comment' |
11 | import { areValidationErrors } from '../utils' | 11 | import { areValidationErrors } from '../utils' |
12 | import { Hooks } from '../../../lib/plugins/hooks' | ||
13 | import { isLocalVideoThreadAccepted, isLocalVideoCommentReplyAccepted, AcceptResult } from '../../../lib/moderation' | ||
12 | 14 | ||
13 | const listVideoCommentThreadsValidator = [ | 15 | const listVideoCommentThreadsValidator = [ |
14 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), | 16 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), |
@@ -48,6 +50,7 @@ const addVideoCommentThreadValidator = [ | |||
48 | if (areValidationErrors(req, res)) return | 50 | if (areValidationErrors(req, res)) return |
49 | if (!await doesVideoExist(req.params.videoId, res)) return | 51 | if (!await doesVideoExist(req.params.videoId, res)) return |
50 | if (!isVideoCommentsEnabled(res.locals.video, res)) return | 52 | if (!isVideoCommentsEnabled(res.locals.video, res)) return |
53 | if (!await isVideoCommentAccepted(req, res, false)) return | ||
51 | 54 | ||
52 | return next() | 55 | return next() |
53 | } | 56 | } |
@@ -65,6 +68,7 @@ const addVideoCommentReplyValidator = [ | |||
65 | if (!await doesVideoExist(req.params.videoId, res)) return | 68 | if (!await doesVideoExist(req.params.videoId, res)) return |
66 | if (!isVideoCommentsEnabled(res.locals.video, res)) return | 69 | if (!isVideoCommentsEnabled(res.locals.video, res)) return |
67 | if (!await doesVideoCommentExist(req.params.commentId, res.locals.video, res)) return | 70 | if (!await doesVideoCommentExist(req.params.commentId, res.locals.video, res)) return |
71 | if (!await isVideoCommentAccepted(req, res, true)) return | ||
68 | 72 | ||
69 | return next() | 73 | return next() |
70 | } | 74 | } |
@@ -193,3 +197,37 @@ function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCom | |||
193 | 197 | ||
194 | return true | 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 | } | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index b1c05ab2d..cb2c071ba 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -33,7 +33,7 @@ import { | |||
33 | import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' | 33 | import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' |
34 | import { logger } from '../../../helpers/logger' | 34 | import { logger } from '../../../helpers/logger' |
35 | import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' | 35 | import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' |
36 | import { authenticatePromiseIfNeeded } from '../../oauth' | 36 | import { authenticate, authenticatePromiseIfNeeded } from '../../oauth' |
37 | import { areValidationErrors } from '../utils' | 37 | import { areValidationErrors } from '../utils' |
38 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 38 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
39 | import { VideoModel } from '../../../models/video/video' | 39 | import { VideoModel } from '../../../models/video/video' |
@@ -44,6 +44,8 @@ import { VideoFetchType } from '../../../helpers/video' | |||
44 | import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' | 44 | import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' |
45 | import { getServerActor } from '../../../helpers/utils' | 45 | import { getServerActor } from '../../../helpers/utils' |
46 | import { CONFIG } from '../../../initializers/config' | 46 | import { CONFIG } from '../../../initializers/config' |
47 | import { isLocalVideoAccepted } from '../../../lib/moderation' | ||
48 | import { Hooks } from '../../../lib/plugins/hooks' | ||
47 | 49 | ||
48 | const videosAddValidator = getCommonVideoEditAttributes().concat([ | 50 | const videosAddValidator = getCommonVideoEditAttributes().concat([ |
49 | body('videofile') | 51 | body('videofile') |
@@ -62,14 +64,12 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([ | |||
62 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 64 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
63 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) | 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 | const user = res.locals.oauth.token.User | 68 | const user = res.locals.oauth.token.User |
67 | 69 | ||
68 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) | 70 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) |
69 | 71 | ||
70 | const isAble = await user.isAbleToUploadVideo(videoFile) | 72 | if (await user.isAbleToUploadVideo(videoFile) === false) { |
71 | |||
72 | if (isAble === false) { | ||
73 | res.status(403) | 73 | res.status(403) |
74 | .json({ error: 'The user video quota is exceeded with this video.' }) | 74 | .json({ error: 'The user video quota is exceeded with this video.' }) |
75 | 75 | ||
@@ -88,7 +88,9 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([ | |||
88 | return cleanUpReqFiles(req) | 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 | return next() | 95 | return next() |
94 | } | 96 | } |
@@ -434,3 +436,26 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) | |||
434 | 436 | ||
435 | return false | 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 | } | ||
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index fee11ec5f..536b6cb3e 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts | |||
@@ -293,7 +293,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> { | |||
293 | return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query) | 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 | const serverActor = await getServerActor() | 305 | const serverActor = await getServerActor() |
298 | const serverAccountId = serverActor.Account.id | 306 | const serverAccountId = serverActor.Account.id |
299 | const userAccountId = user ? user.Account.id : undefined | 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 | const serverActor = await getServerActor() | 346 | const serverActor = await getServerActor() |
333 | const serverAccountId = serverActor.Account.id | 347 | const serverAccountId = serverActor.Account.id |
334 | const userAccountId = user ? user.Account.id : undefined | 348 | const userAccountId = user ? user.Account.id : undefined |
diff --git a/shared/core-utils/miscs/miscs.ts b/shared/core-utils/miscs/miscs.ts index a3921b568..5de024c08 100644 --- a/shared/core-utils/miscs/miscs.ts +++ b/shared/core-utils/miscs/miscs.ts | |||
@@ -19,7 +19,17 @@ function compareSemVer (a: string, b: string) { | |||
19 | return segmentsA.length - segmentsB.length | 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 | export { | 30 | export { |
23 | randomInt, | 31 | randomInt, |
24 | compareSemVer | 32 | compareSemVer, |
33 | isPromise, | ||
34 | isCatchable | ||
25 | } | 35 | } |
diff --git a/shared/core-utils/plugins/hooks.ts b/shared/core-utils/plugins/hooks.ts new file mode 100644 index 000000000..047c04f7b --- /dev/null +++ b/shared/core-utils/plugins/hooks.ts | |||
@@ -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 | } | ||
diff --git a/shared/models/plugins/hook-type.enum.ts b/shared/models/plugins/hook-type.enum.ts new file mode 100644 index 000000000..66d24071c --- /dev/null +++ b/shared/models/plugins/hook-type.enum.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export enum HookType { | ||
2 | STATIC = 1, | ||
3 | ACTION = 2, | ||
4 | FILTER = 3 | ||
5 | } | ||
diff --git a/shared/models/plugins/server-hook.model.ts b/shared/models/plugins/server-hook.model.ts new file mode 100644 index 000000000..30469856c --- /dev/null +++ b/shared/models/plugins/server-hook.model.ts | |||
@@ -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 | } | ||
diff --git a/tslint.json b/tslint.json index 6828f4325..cfe2ac712 100644 --- a/tslint.json +++ b/tslint.json | |||
@@ -5,7 +5,13 @@ | |||
5 | "no-inferrable-types": true, | 5 | "no-inferrable-types": true, |
6 | "eofline": true, | 6 | "eofline": true, |
7 | "indent": [true, "spaces"], | 7 | "indent": [true, "spaces"], |
8 | "ter-indent": [true, 2], | 8 | "ter-indent": [ |
9 | true, | ||
10 | 2, | ||
11 | { | ||
12 | "SwitchCase": 1 | ||
13 | } | ||
14 | ], | ||
9 | "max-line-length": [true, 140], | 15 | "max-line-length": [true, 140], |
10 | "no-unused-variable": false, // Memory issues | 16 | "no-unused-variable": false, // Memory issues |
11 | "no-floating-promises": false | 17 | "no-floating-promises": false |