From 6e46de095d7169355dd83030f6ce4a582304153a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 5 Oct 2018 11:15:06 +0200 Subject: Add user history and resume videos --- server/controllers/activitypub/client.ts | 3 +- server/controllers/api/search.ts | 3 +- server/controllers/api/videos/captions.ts | 6 +- server/controllers/api/videos/comment.ts | 6 +- server/controllers/api/videos/index.ts | 6 +- server/controllers/api/videos/watching.ts | 36 ++ server/helpers/custom-validators/videos.ts | 4 +- server/helpers/video.ts | 4 +- server/initializers/database.ts | 4 +- server/lib/redis.ts | 54 ++- server/middlewares/cache.ts | 2 +- server/middlewares/validators/index.ts | 4 - server/middlewares/validators/video-abuses.ts | 71 ---- server/middlewares/validators/video-blacklist.ts | 62 ---- server/middlewares/validators/video-captions.ts | 71 ---- server/middlewares/validators/video-channels.ts | 175 --------- server/middlewares/validators/video-comments.ts | 195 ---------- server/middlewares/validators/video-imports.ts | 75 ---- server/middlewares/validators/videos.ts | 399 --------------------- server/middlewares/validators/videos/index.ts | 8 + .../middlewares/validators/videos/video-abuses.ts | 71 ++++ .../validators/videos/video-blacklist.ts | 62 ++++ .../validators/videos/video-captions.ts | 71 ++++ .../validators/videos/video-channels.ts | 175 +++++++++ .../validators/videos/video-comments.ts | 195 ++++++++++ .../middlewares/validators/videos/video-imports.ts | 75 ++++ .../middlewares/validators/videos/video-watch.ts | 28 ++ server/middlewares/validators/videos/videos.ts | 399 +++++++++++++++++++++ server/models/account/user-video-history.ts | 55 +++ server/models/video/video-format-utils.ts | 9 +- server/models/video/video.ts | 80 ++++- server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/videos-history.ts | 79 ++++ server/tests/api/videos/index.ts | 1 + server/tests/api/videos/videos-history.ts | 128 +++++++ server/tests/utils/videos/video-history.ts | 14 + 36 files changed, 1533 insertions(+), 1098 deletions(-) create mode 100644 server/controllers/api/videos/watching.ts delete mode 100644 server/middlewares/validators/video-abuses.ts delete mode 100644 server/middlewares/validators/video-blacklist.ts delete mode 100644 server/middlewares/validators/video-captions.ts delete mode 100644 server/middlewares/validators/video-channels.ts delete mode 100644 server/middlewares/validators/video-comments.ts delete mode 100644 server/middlewares/validators/video-imports.ts delete mode 100644 server/middlewares/validators/videos.ts create mode 100644 server/middlewares/validators/videos/index.ts create mode 100644 server/middlewares/validators/videos/video-abuses.ts create mode 100644 server/middlewares/validators/videos/video-blacklist.ts create mode 100644 server/middlewares/validators/videos/video-captions.ts create mode 100644 server/middlewares/validators/videos/video-channels.ts create mode 100644 server/middlewares/validators/videos/video-comments.ts create mode 100644 server/middlewares/validators/videos/video-imports.ts create mode 100644 server/middlewares/validators/videos/video-watch.ts create mode 100644 server/middlewares/validators/videos/videos.ts create mode 100644 server/models/account/user-video-history.ts create mode 100644 server/tests/api/check-params/videos-history.ts create mode 100644 server/tests/api/videos/videos-history.ts create mode 100644 server/tests/utils/videos/video-history.ts (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 6229c44aa..433186179 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -13,8 +13,7 @@ import { localVideoChannelValidator, videosCustomGetValidator } from '../../middlewares' -import { videosGetValidator, videosShareValidator } from '../../middlewares/validators' -import { videoCommentGetValidator } from '../../middlewares/validators/video-comments' +import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators' import { AccountModel } from '../../models/account/account' import { ActorModel } from '../../models/activitypub/actor' import { ActorFollowModel } from '../../models/activitypub/actor-follow' diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index fd4db7a54..4be2b5ef7 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -117,7 +117,8 @@ function searchVideos (req: express.Request, res: express.Response) { async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { const options = Object.assign(query, { includeLocalVideos: true, - nsfw: buildNSFWFilter(res, query.nsfw) + nsfw: buildNSFWFilter(res, query.nsfw), + userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined }) const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts index 4cf8de1ef..3ba918189 100644 --- a/server/controllers/api/videos/captions.ts +++ b/server/controllers/api/videos/captions.ts @@ -1,10 +1,6 @@ import * as express from 'express' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' -import { - addVideoCaptionValidator, - deleteVideoCaptionValidator, - listVideoCaptionsValidator -} from '../../../middlewares/validators/video-captions' +import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' import { createReqFiles } from '../../../helpers/express-utils' import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' import { getFormattedObjects } from '../../../helpers/utils' diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index dc25e1e85..4f2b4faee 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -13,14 +13,14 @@ import { setDefaultPagination, setDefaultSort } from '../../../middlewares' -import { videoCommentThreadsSortValidator } from '../../../middlewares/validators' import { addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator, listVideoThreadCommentsValidator, - removeVideoCommentValidator -} from '../../../middlewares/validators/video-comments' + removeVideoCommentValidator, + videoCommentThreadsSortValidator +} from '../../../middlewares/validators' import { VideoModel } from '../../../models/video/video' import { VideoCommentModel } from '../../../models/video/video-comment' import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 15ef8d458..6a73e13d0 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -57,6 +57,7 @@ import { videoCaptionsRouter } from './captions' import { videoImportsRouter } from './import' import { resetSequelizeInstance } from '../../../helpers/database-utils' import { rename } from 'fs-extra' +import { watchingRouter } from './watching' const auditLogger = auditLoggerFactory('videos') const videosRouter = express.Router() @@ -86,6 +87,7 @@ videosRouter.use('/', videoCommentRouter) videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoImportsRouter) videosRouter.use('/', ownershipVideoRouter) +videosRouter.use('/', watchingRouter) videosRouter.get('/categories', listVideoCategories) videosRouter.get('/licences', listVideoLicences) @@ -119,6 +121,7 @@ videosRouter.get('/:id/description', asyncMiddleware(getVideoDescription) ) videosRouter.get('/:id', + optionalAuthenticate, asyncMiddleware(videosGetValidator), getVideo ) @@ -433,7 +436,8 @@ async function listVideos (req: express.Request, res: express.Response, next: ex tagsAllOf: req.query.tagsAllOf, nsfw: buildNSFWFilter(res, req.query.nsfw), filter: req.query.filter as VideoFilter, - withFiles: false + withFiles: false, + userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined }) return res.json(getFormattedObjects(resultList.data, resultList.total)) diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts new file mode 100644 index 000000000..e8876b47a --- /dev/null +++ b/server/controllers/api/videos/watching.ts @@ -0,0 +1,36 @@ +import * as express from 'express' +import { UserWatchingVideo } from '../../../../shared' +import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares' +import { UserVideoHistoryModel } from '../../../models/account/user-video-history' +import { UserModel } from '../../../models/account/user' + +const watchingRouter = express.Router() + +watchingRouter.put('/:videoId/watching', + authenticate, + asyncMiddleware(videoWatchingValidator), + asyncRetryTransactionMiddleware(userWatchVideo) +) + +// --------------------------------------------------------------------------- + +export { + watchingRouter +} + +// --------------------------------------------------------------------------- + +async function userWatchVideo (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.User as UserModel + + const body: UserWatchingVideo = req.body + const { id: videoId } = res.locals.video as { id: number } + + await UserVideoHistoryModel.upsert({ + videoId, + userId: user.id, + currentTime: body.currentTime + }) + + return res.type('json').status(204).end() +} diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 9875c68bd..714f7ac95 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -154,7 +154,9 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use } async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { - const video = await fetchVideo(id, fetchType) + const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined + + const video = await fetchVideo(id, fetchType, userId) if (video === null) { res.status(404) diff --git a/server/helpers/video.ts b/server/helpers/video.ts index b1577a6b0..1bd21467d 100644 --- a/server/helpers/video.ts +++ b/server/helpers/video.ts @@ -2,8 +2,8 @@ import { VideoModel } from '../models/video/video' type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' -function fetchVideo (id: number | string, fetchType: VideoFetchType) { - if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id) +function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) { + if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId) if (fetchType === 'only-video') return VideoModel.load(id) diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 4d57bf8aa..482c03b31 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -28,6 +28,7 @@ import { VideoImportModel } from '../models/video/video-import' import { VideoViewModel } from '../models/video/video-views' import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' +import { UserVideoHistoryModel } from '../models/account/user-video-history' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -89,7 +90,8 @@ async function initDatabaseModels (silent: boolean) { ScheduleVideoUpdateModel, VideoImportModel, VideoViewModel, - VideoRedundancyModel + VideoRedundancyModel, + UserVideoHistoryModel ]) // Check extensions exist in the database diff --git a/server/lib/redis.ts b/server/lib/redis.ts index e4e435659..abd75d512 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -48,6 +48,8 @@ class Redis { ) } + /************* Forgot password *************/ + async setResetPasswordVerificationString (userId: number) { const generatedString = await generateRandomString(32) @@ -60,6 +62,8 @@ class Redis { return this.getValue(this.generateResetPasswordKey(userId)) } + /************* Email verification *************/ + async setVerifyEmailVerificationString (userId: number) { const generatedString = await generateRandomString(32) @@ -72,16 +76,20 @@ class Redis { return this.getValue(this.generateVerifyEmailKey(userId)) } + /************* Views per IP *************/ + setIPVideoView (ip: string, videoUUID: string) { - return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) + return this.setValue(this.generateViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) } async isVideoIPViewExists (ip: string, videoUUID: string) { - return this.exists(this.buildViewKey(ip, videoUUID)) + return this.exists(this.generateViewKey(ip, videoUUID)) } + /************* API cache *************/ + async getCachedRoute (req: express.Request) { - const cached = await this.getObject(this.buildCachedRouteKey(req)) + const cached = await this.getObject(this.generateCachedRouteKey(req)) return cached as CachedRoute } @@ -94,9 +102,11 @@ class Redis { (statusCode) ? { statusCode: statusCode.toString() } : null ) - return this.setObject(this.buildCachedRouteKey(req), cached, lifetime) + return this.setObject(this.generateCachedRouteKey(req), cached, lifetime) } + /************* Video views *************/ + addVideoView (videoId: number) { const keyIncr = this.generateVideoViewKey(videoId) const keySet = this.generateVideosViewKey() @@ -131,33 +141,37 @@ class Redis { ]) } - generateVideosViewKey (hour?: number) { + /************* Keys generation *************/ + + generateCachedRouteKey (req: express.Request) { + return req.method + '-' + req.originalUrl + } + + private generateVideosViewKey (hour?: number) { if (!hour) hour = new Date().getHours() return `videos-view-h${hour}` } - generateVideoViewKey (videoId: number, hour?: number) { + private generateVideoViewKey (videoId: number, hour?: number) { if (!hour) hour = new Date().getHours() return `video-view-${videoId}-h${hour}` } - generateResetPasswordKey (userId: number) { + private generateResetPasswordKey (userId: number) { return 'reset-password-' + userId } - generateVerifyEmailKey (userId: number) { + private generateVerifyEmailKey (userId: number) { return 'verify-email-' + userId } - buildViewKey (ip: string, videoUUID: string) { + private generateViewKey (ip: string, videoUUID: string) { return videoUUID + '-' + ip } - buildCachedRouteKey (req: express.Request) { - return req.method + '-' + req.originalUrl - } + /************* Redis helpers *************/ private getValue (key: string) { return new Promise((res, rej) => { @@ -197,6 +211,12 @@ class Redis { }) } + private deleteFieldInHash (key: string, field: string) { + return new Promise((res, rej) => { + this.client.hdel(this.prefix + key, field, err => err ? rej(err) : res()) + }) + } + private setValue (key: string, value: string, expirationMilliseconds: number) { return new Promise((res, rej) => { this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { @@ -235,6 +255,16 @@ class Redis { }) } + private setValueInHash (key: string, field: string, value: string) { + return new Promise((res, rej) => { + this.client.hset(this.prefix + key, field, value, (err) => { + if (err) return rej(err) + + return res() + }) + }) + } + private increment (key: string) { return new Promise((res, rej) => { this.client.incr(this.prefix + key, (err, value) => { diff --git a/server/middlewares/cache.ts b/server/middlewares/cache.ts index 1b44957d3..1e00fc731 100644 --- a/server/middlewares/cache.ts +++ b/server/middlewares/cache.ts @@ -8,7 +8,7 @@ const lock = new AsyncLock({ timeout: 5000 }) function cacheRoute (lifetimeArg: string | number) { return async function (req: express.Request, res: express.Response, next: express.NextFunction) { - const redisKey = Redis.Instance.buildCachedRouteKey(req) + const redisKey = Redis.Instance.generateCachedRouteKey(req) try { await lock.acquire(redisKey, async (done) => { diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 940547a3e..17226614c 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -8,9 +8,5 @@ export * from './sort' export * from './users' export * from './user-subscriptions' export * from './videos' -export * from './video-abuses' -export * from './video-blacklist' -export * from './video-channels' export * from './webfinger' export * from './search' -export * from './video-imports' diff --git a/server/middlewares/validators/video-abuses.ts b/server/middlewares/validators/video-abuses.ts deleted file mode 100644 index f15d55a75..000000000 --- a/server/middlewares/validators/video-abuses.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as express from 'express' -import 'express-validator' -import { body, param } from 'express-validator/check' -import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' -import { - isVideoAbuseExist, - isVideoAbuseModerationCommentValid, - isVideoAbuseReasonValid, - isVideoAbuseStateValid -} from '../../helpers/custom-validators/video-abuses' - -const videoAbuseReportValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - - return next() - } -] - -const videoAbuseGetValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoAbuseExist(req.params.id, res.locals.video.id, res)) return - - return next() - } -] - -const videoAbuseUpdateValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), - body('state') - .optional() - .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'), - body('moderationComment') - .optional() - .custom(isVideoAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoAbuseExist(req.params.id, res.locals.video.id, res)) return - - return next() - } -] - -// --------------------------------------------------------------------------- - -export { - videoAbuseReportValidator, - videoAbuseGetValidator, - videoAbuseUpdateValidator -} diff --git a/server/middlewares/validators/video-blacklist.ts b/server/middlewares/validators/video-blacklist.ts deleted file mode 100644 index 95a2b9f17..000000000 --- a/server/middlewares/validators/video-blacklist.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as express from 'express' -import { body, param } from 'express-validator/check' -import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' -import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' - -const videosBlacklistRemoveValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoBlacklistExist(res.locals.video.id, res)) return - - return next() - } -] - -const videosBlacklistAddValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - body('reason') - .optional() - .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosBlacklistAdd parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - - return next() - } -] - -const videosBlacklistUpdateValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - body('reason') - .optional() - .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosBlacklistUpdate parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoBlacklistExist(res.locals.video.id, res)) return - - return next() - } -] - -// --------------------------------------------------------------------------- - -export { - videosBlacklistAddValidator, - videosBlacklistRemoveValidator, - videosBlacklistUpdateValidator -} diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/video-captions.ts deleted file mode 100644 index 51ffd7f3c..000000000 --- a/server/middlewares/validators/video-captions.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as express from 'express' -import { areValidationErrors } from './utils' -import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' -import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' -import { body, param } from 'express-validator/check' -import { CONSTRAINTS_FIELDS } from '../../initializers' -import { UserRight } from '../../../shared' -import { logger } from '../../helpers/logger' -import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' -import { cleanUpReqFiles } from '../../helpers/express-utils' - -const addVideoCaptionValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), - param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), - body('captionfile') - .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage( - 'This caption file is not supported or too large. Please, make sure it is of the following type : ' - + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ') - ), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking addVideoCaption parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) - if (!await isVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req) - - // Check if the user who did the request is able to update the video - const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) - - return next() - } -] - -const deleteVideoCaptionValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), - param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return - - // Check if the user who did the request is able to update the video - const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return - - return next() - } -] - -const listVideoCaptionsValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking listVideoCaptions parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res, 'id')) return - - return next() - } -] - -export { - addVideoCaptionValidator, - listVideoCaptionsValidator, - deleteVideoCaptionValidator -} diff --git a/server/middlewares/validators/video-channels.ts b/server/middlewares/validators/video-channels.ts deleted file mode 100644 index 56a347b39..000000000 --- a/server/middlewares/validators/video-channels.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as express from 'express' -import { body, param } from 'express-validator/check' -import { UserRight } from '../../../shared' -import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' -import { - isLocalVideoChannelNameExist, - isVideoChannelDescriptionValid, - isVideoChannelNameValid, - isVideoChannelNameWithHostExist, - isVideoChannelSupportValid -} from '../../helpers/custom-validators/video-channels' -import { logger } from '../../helpers/logger' -import { UserModel } from '../../models/account/user' -import { VideoChannelModel } from '../../models/video/video-channel' -import { areValidationErrors } from './utils' -import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' -import { ActorModel } from '../../models/activitypub/actor' - -const listVideoAccountChannelsValidator = [ - param('accountName').exists().withMessage('Should have a valid account name'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isAccountNameWithHostExist(req.params.accountName, res)) return - - return next() - } -] - -const videoChannelsAddValidator = [ - body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), - body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), - body('description').optional().custom(isVideoChannelDescriptionValid).withMessage('Should have a valid description'), - body('support').optional().custom(isVideoChannelSupportValid).withMessage('Should have a valid support text'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoChannelsAdd parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - - const actor = await ActorModel.loadLocalByName(req.body.name) - if (actor) { - res.status(409) - .send({ error: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' }) - .end() - return false - } - - return next() - } -] - -const videoChannelsUpdateValidator = [ - param('nameWithHost').exists().withMessage('Should have an video channel name with host'), - body('displayName').optional().custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), - body('description').optional().custom(isVideoChannelDescriptionValid).withMessage('Should have a valid description'), - body('support').optional().custom(isVideoChannelSupportValid).withMessage('Should have a valid support text'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return - - // We need to make additional checks - if (res.locals.videoChannel.Actor.isOwned() === false) { - return res.status(403) - .json({ error: 'Cannot update video channel of another server' }) - .end() - } - - if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) { - return res.status(403) - .json({ error: 'Cannot update video channel of another user' }) - .end() - } - - return next() - } -] - -const videoChannelsRemoveValidator = [ - param('nameWithHost').exists().withMessage('Should have an video channel name with host'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoChannelsRemove parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return - - if (!checkUserCanDeleteVideoChannel(res.locals.oauth.token.User, res.locals.videoChannel, res)) return - if (!await checkVideoChannelIsNotTheLastOne(res)) return - - return next() - } -] - -const videoChannelsNameWithHostValidator = [ - param('nameWithHost').exists().withMessage('Should have an video channel name with host'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoChannelsNameWithHostValidator parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - - if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return - - return next() - } -] - -const localVideoChannelValidator = [ - param('name').custom(isVideoChannelNameValid).withMessage('Should have a valid video channel name'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking localVideoChannelValidator parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isLocalVideoChannelNameExist(req.params.name, res)) return - - return next() - } -] - -// --------------------------------------------------------------------------- - -export { - listVideoAccountChannelsValidator, - videoChannelsAddValidator, - videoChannelsUpdateValidator, - videoChannelsRemoveValidator, - videoChannelsNameWithHostValidator, - localVideoChannelValidator -} - -// --------------------------------------------------------------------------- - -function checkUserCanDeleteVideoChannel (user: UserModel, videoChannel: VideoChannelModel, res: express.Response) { - if (videoChannel.Actor.isOwned() === false) { - res.status(403) - .json({ error: 'Cannot remove video channel of another server.' }) - .end() - - return false - } - - // Check if the user can delete the video channel - // The user can delete it if s/he is an admin - // Or if s/he is the video channel's account - if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_CHANNEL) === false && videoChannel.Account.userId !== user.id) { - res.status(403) - .json({ error: 'Cannot remove video channel of another user' }) - .end() - - return false - } - - return true -} - -async function checkVideoChannelIsNotTheLastOne (res: express.Response) { - const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id) - - if (count <= 1) { - res.status(409) - .json({ error: 'Cannot remove the last channel of this user' }) - .end() - - return false - } - - return true -} diff --git a/server/middlewares/validators/video-comments.ts b/server/middlewares/validators/video-comments.ts deleted file mode 100644 index 693852499..000000000 --- a/server/middlewares/validators/video-comments.ts +++ /dev/null @@ -1,195 +0,0 @@ -import * as express from 'express' -import { body, param } from 'express-validator/check' -import { UserRight } from '../../../shared' -import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' -import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' -import { isVideoExist } from '../../helpers/custom-validators/videos' -import { logger } from '../../helpers/logger' -import { UserModel } from '../../models/account/user' -import { VideoModel } from '../../models/video/video' -import { VideoCommentModel } from '../../models/video/video-comment' -import { areValidationErrors } from './utils' - -const listVideoCommentThreadsValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res, 'only-video')) return - - return next() - } -] - -const listVideoThreadCommentsValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('threadId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res, 'only-video')) return - if (!await isVideoCommentThreadExist(req.params.threadId, res.locals.video, res)) return - - return next() - } -] - -const addVideoCommentThreadValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking addVideoCommentThread parameters.', { parameters: req.params, body: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!isVideoCommentsEnabled(res.locals.video, res)) return - - return next() - } -] - -const addVideoCommentReplyValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), - body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking addVideoCommentReply parameters.', { parameters: req.params, body: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!isVideoCommentsEnabled(res.locals.video, res)) return - if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return - - return next() - } -] - -const videoCommentGetValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res, 'id')) return - if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return - - return next() - } -] - -const removeVideoCommentValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking removeVideoCommentValidator parameters.', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return - - // Check if the user who did the request is able to delete the video - if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoComment, res)) return - - return next() - } -] - -// --------------------------------------------------------------------------- - -export { - listVideoCommentThreadsValidator, - listVideoThreadCommentsValidator, - addVideoCommentThreadValidator, - addVideoCommentReplyValidator, - videoCommentGetValidator, - removeVideoCommentValidator -} - -// --------------------------------------------------------------------------- - -async function isVideoCommentThreadExist (id: number, video: VideoModel, res: express.Response) { - const videoComment = await VideoCommentModel.loadById(id) - - if (!videoComment) { - res.status(404) - .json({ error: 'Video comment thread not found' }) - .end() - - return false - } - - if (videoComment.videoId !== video.id) { - res.status(400) - .json({ error: 'Video comment is associated to this video.' }) - .end() - - return false - } - - if (videoComment.inReplyToCommentId !== null) { - res.status(400) - .json({ error: 'Video comment is not a thread.' }) - .end() - - return false - } - - res.locals.videoCommentThread = videoComment - return true -} - -async function isVideoCommentExist (id: number, video: VideoModel, res: express.Response) { - const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id) - - if (!videoComment) { - res.status(404) - .json({ error: 'Video comment thread not found' }) - .end() - - return false - } - - if (videoComment.videoId !== video.id) { - res.status(400) - .json({ error: 'Video comment is associated to this video.' }) - .end() - - return false - } - - res.locals.videoComment = videoComment - return true -} - -function isVideoCommentsEnabled (video: VideoModel, res: express.Response) { - if (video.commentsEnabled !== true) { - res.status(409) - .json({ error: 'Video comments are disabled for this video.' }) - .end() - - return false - } - - return true -} - -function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCommentModel, res: express.Response) { - const account = videoComment.Account - if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) === false && account.userId !== user.id) { - res.status(403) - .json({ error: 'Cannot remove video comment of another user' }) - .end() - return false - } - - return true -} diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/video-imports.ts deleted file mode 100644 index b2063b8da..000000000 --- a/server/middlewares/validators/video-imports.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as express from 'express' -import { body } from 'express-validator/check' -import { isIdValid } from '../../helpers/custom-validators/misc' -import { logger } from '../../helpers/logger' -import { areValidationErrors } from './utils' -import { getCommonVideoAttributes } from './videos' -import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports' -import { cleanUpReqFiles } from '../../helpers/express-utils' -import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' -import { CONFIG } from '../../initializers/constants' -import { CONSTRAINTS_FIELDS } from '../../initializers' - -const videoImportAddValidator = getCommonVideoAttributes().concat([ - body('channelId') - .toInt() - .custom(isIdValid).withMessage('Should have correct video channel id'), - body('targetUrl') - .optional() - .custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'), - body('magnetUri') - .optional() - .custom(isVideoMagnetUriValid).withMessage('Should have a valid video magnet URI'), - body('torrentfile') - .custom((value, { req }) => isVideoImportTorrentFile(req.files)).withMessage( - 'This torrent file is not supported or too large. Please, make sure it is of the following type: ' - + CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.EXTNAME.join(', ') - ), - body('name') - .optional() - .custom(isVideoNameValid).withMessage('Should have a valid name'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body }) - - const user = res.locals.oauth.token.User - const torrentFile = req.files && req.files['torrentfile'] ? req.files['torrentfile'][0] : undefined - - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) - - if (req.body.targetUrl && CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true) { - cleanUpReqFiles(req) - return res.status(409) - .json({ error: 'HTTP import is not enabled on this instance.' }) - .end() - } - - if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) { - cleanUpReqFiles(req) - return res.status(409) - .json({ error: 'Torrent/magnet URI import is not enabled on this instance.' }) - .end() - } - - if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) - - // Check we have at least 1 required param - if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) { - cleanUpReqFiles(req) - - return res.status(400) - .json({ error: 'Should have a magnetUri or a targetUrl or a torrent file.' }) - .end() - } - - return next() - } -]) - -// --------------------------------------------------------------------------- - -export { - videoImportAddValidator -} - -// --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts deleted file mode 100644 index 67eabe468..000000000 --- a/server/middlewares/validators/videos.ts +++ /dev/null @@ -1,399 +0,0 @@ -import * as express from 'express' -import 'express-validator' -import { body, param, ValidationChain } from 'express-validator/check' -import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' -import { - isBooleanValid, - isDateValid, - isIdOrUUIDValid, - isIdValid, - isUUIDValid, - toIntOrNull, - toValueOrNull -} from '../../helpers/custom-validators/misc' -import { - checkUserCanManageVideo, - isScheduleVideoUpdatePrivacyValid, - isVideoCategoryValid, - isVideoChannelOfAccountExist, - isVideoDescriptionValid, - isVideoExist, - isVideoFile, - isVideoImage, - isVideoLanguageValid, - isVideoLicenceValid, - isVideoNameValid, - isVideoPrivacyValid, - isVideoRatingTypeValid, - isVideoSupportValid, - isVideoTagsValid -} from '../../helpers/custom-validators/videos' -import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' -import { logger } from '../../helpers/logger' -import { CONSTRAINTS_FIELDS } from '../../initializers' -import { VideoShareModel } from '../../models/video/video-share' -import { authenticate } from '../oauth' -import { areValidationErrors } from './utils' -import { cleanUpReqFiles } from '../../helpers/express-utils' -import { VideoModel } from '../../models/video/video' -import { UserModel } from '../../models/account/user' -import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' -import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' -import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' -import { AccountModel } from '../../models/account/account' -import { VideoFetchType } from '../../helpers/video' - -const videosAddValidator = getCommonVideoAttributes().concat([ - body('videofile') - .custom((value, { req }) => isVideoFile(req.files)).withMessage( - 'This file is not supported or too large. Please, make sure it is of the following type: ' - + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') - ), - body('name').custom(isVideoNameValid).withMessage('Should have a valid name'), - body('channelId') - .toInt() - .custom(isIdValid).withMessage('Should have correct video channel id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) - - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) - if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) - - const videoFile: Express.Multer.File = req.files['videofile'][0] - const user = res.locals.oauth.token.User - - if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) - - const isAble = await user.isAbleToUploadVideo(videoFile) - if (isAble === false) { - res.status(403) - .json({ error: 'The user video quota is exceeded with this video.' }) - .end() - - return cleanUpReqFiles(req) - } - - let duration: number - - try { - duration = await getDurationFromVideoFile(videoFile.path) - } catch (err) { - logger.error('Invalid input file in videosAddValidator.', { err }) - res.status(400) - .json({ error: 'Invalid input file.' }) - .end() - - return cleanUpReqFiles(req) - } - - videoFile['duration'] = duration - - return next() - } -]) - -const videosUpdateValidator = getCommonVideoAttributes().concat([ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - body('name') - .optional() - .custom(isVideoNameValid).withMessage('Should have a valid name'), - body('channelId') - .optional() - .toInt() - .custom(isIdValid).withMessage('Should have correct video channel id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosUpdate parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return cleanUpReqFiles(req) - if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) - if (!await isVideoExist(req.params.id, res)) return cleanUpReqFiles(req) - - const video = res.locals.video - - // Check if the user who did the request is able to update the video - const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) - - if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) { - cleanUpReqFiles(req) - return res.status(409) - .json({ error: 'Cannot set "private" a video that was not private.' }) - .end() - } - - if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) - - return next() - } -]) - -const videosCustomGetValidator = (fetchType: VideoFetchType) => { - return [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosGet parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res, fetchType)) return - - const video: VideoModel = res.locals.video - - // Video private or blacklisted - if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) { - return authenticate(req, res, () => { - const user: UserModel = res.locals.oauth.token.User - - // Only the owner or a user that have blacklist rights can see the video - if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { - return res.status(403) - .json({ error: 'Cannot get this private or blacklisted video.' }) - .end() - } - - return next() - }) - } - - // Video is public, anyone can access it - if (video.privacy === VideoPrivacy.PUBLIC) return next() - - // Video is unlisted, check we used the uuid to fetch it - if (video.privacy === VideoPrivacy.UNLISTED) { - if (isUUIDValid(req.params.id)) return next() - - // Don't leak this unlisted video - return res.status(404).end() - } - } - ] -} - -const videosGetValidator = videosCustomGetValidator('all') - -const videosRemoveValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videosRemove parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res)) return - - // Check if the user who did the request is able to delete the video - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.REMOVE_ANY_VIDEO, res)) return - - return next() - } -] - -const videoRateValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoRate parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res)) return - - return next() - } -] - -const videosShareValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoShare parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res)) return - - const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined) - if (!share) { - return res.status(404) - .end() - } - - res.locals.videoShare = share - return next() - } -] - -const videosChangeOwnershipValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking changeOwnership parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.videoId, res)) return - - // Check if the user who did the request is able to change the ownership of the video - if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return - - const nextOwner = await AccountModel.loadLocalByName(req.body.username) - if (!nextOwner) { - res.status(400) - .type('json') - .end() - return - } - res.locals.nextOwner = nextOwner - - return next() - } -] - -const videosTerminateChangeOwnershipValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking changeOwnership parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return - - // Check if the user who did the request is able to change the ownership of the video - if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return - - return next() - }, - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel - - if (videoChangeOwnership.status === VideoChangeOwnershipStatus.WAITING) { - return next() - } else { - res.status(403) - .json({ error: 'Ownership already accepted or refused' }) - .end() - return - } - } -] - -const videosAcceptChangeOwnershipValidator = [ - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - const body = req.body as VideoChangeOwnershipAccept - if (!await isVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return - - const user = res.locals.oauth.token.User - const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel - const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getOriginalFile()) - if (isAble === false) { - res.status(403) - .json({ error: 'The user video quota is exceeded with this video.' }) - .end() - return - } - - return next() - } -] - -function getCommonVideoAttributes () { - return [ - body('thumbnailfile') - .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( - 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' - + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') - ), - body('previewfile') - .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( - 'This preview file is not supported or too large. Please, make sure it is of the following type: ' - + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') - ), - - body('category') - .optional() - .customSanitizer(toIntOrNull) - .custom(isVideoCategoryValid).withMessage('Should have a valid category'), - body('licence') - .optional() - .customSanitizer(toIntOrNull) - .custom(isVideoLicenceValid).withMessage('Should have a valid licence'), - body('language') - .optional() - .customSanitizer(toValueOrNull) - .custom(isVideoLanguageValid).withMessage('Should have a valid language'), - body('nsfw') - .optional() - .toBoolean() - .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'), - body('waitTranscoding') - .optional() - .toBoolean() - .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'), - body('privacy') - .optional() - .toInt() - .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), - body('description') - .optional() - .customSanitizer(toValueOrNull) - .custom(isVideoDescriptionValid).withMessage('Should have a valid description'), - body('support') - .optional() - .customSanitizer(toValueOrNull) - .custom(isVideoSupportValid).withMessage('Should have a valid support text'), - body('tags') - .optional() - .customSanitizer(toValueOrNull) - .custom(isVideoTagsValid).withMessage('Should have correct tags'), - body('commentsEnabled') - .optional() - .toBoolean() - .custom(isBooleanValid).withMessage('Should have comments enabled boolean'), - - body('scheduleUpdate') - .optional() - .customSanitizer(toValueOrNull), - body('scheduleUpdate.updateAt') - .optional() - .custom(isDateValid).withMessage('Should have a valid schedule update date'), - body('scheduleUpdate.privacy') - .optional() - .toInt() - .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy') - ] as (ValidationChain | express.Handler)[] -} - -// --------------------------------------------------------------------------- - -export { - videosAddValidator, - videosUpdateValidator, - videosGetValidator, - videosCustomGetValidator, - videosRemoveValidator, - videosShareValidator, - - videoRateValidator, - - videosChangeOwnershipValidator, - videosTerminateChangeOwnershipValidator, - videosAcceptChangeOwnershipValidator, - - getCommonVideoAttributes -} - -// --------------------------------------------------------------------------- - -function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) { - if (req.body.scheduleUpdate) { - if (!req.body.scheduleUpdate.updateAt) { - res.status(400) - .json({ error: 'Schedule update at is mandatory.' }) - .end() - - return true - } - } - - return false -} diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts new file mode 100644 index 000000000..294783d85 --- /dev/null +++ b/server/middlewares/validators/videos/index.ts @@ -0,0 +1,8 @@ +export * from './video-abuses' +export * from './video-blacklist' +export * from './video-captions' +export * from './video-channels' +export * from './video-comments' +export * from './video-imports' +export * from './video-watch' +export * from './videos' diff --git a/server/middlewares/validators/videos/video-abuses.ts b/server/middlewares/validators/videos/video-abuses.ts new file mode 100644 index 000000000..be26ca16a --- /dev/null +++ b/server/middlewares/validators/videos/video-abuses.ts @@ -0,0 +1,71 @@ +import * as express from 'express' +import 'express-validator' +import { body, param } from 'express-validator/check' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' +import { + isVideoAbuseExist, + isVideoAbuseModerationCommentValid, + isVideoAbuseReasonValid, + isVideoAbuseStateValid +} from '../../../helpers/custom-validators/video-abuses' + +const videoAbuseReportValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + return next() + } +] + +const videoAbuseGetValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoAbuseExist(req.params.id, res.locals.video.id, res)) return + + return next() + } +] + +const videoAbuseUpdateValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), + body('state') + .optional() + .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'), + body('moderationComment') + .optional() + .custom(isVideoAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoAbuseExist(req.params.id, res.locals.video.id, res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + videoAbuseReportValidator, + videoAbuseGetValidator, + videoAbuseUpdateValidator +} diff --git a/server/middlewares/validators/videos/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts new file mode 100644 index 000000000..13da7acff --- /dev/null +++ b/server/middlewares/validators/videos/video-blacklist.ts @@ -0,0 +1,62 @@ +import * as express from 'express' +import { body, param } from 'express-validator/check' +import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' +import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist' + +const videosBlacklistRemoveValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoBlacklistExist(res.locals.video.id, res)) return + + return next() + } +] + +const videosBlacklistAddValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + body('reason') + .optional() + .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosBlacklistAdd parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + return next() + } +] + +const videosBlacklistUpdateValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + body('reason') + .optional() + .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosBlacklistUpdate parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoBlacklistExist(res.locals.video.id, res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + videosBlacklistAddValidator, + videosBlacklistRemoveValidator, + videosBlacklistUpdateValidator +} diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts new file mode 100644 index 000000000..63d84fbec --- /dev/null +++ b/server/middlewares/validators/videos/video-captions.ts @@ -0,0 +1,71 @@ +import * as express from 'express' +import { areValidationErrors } from '../utils' +import { checkUserCanManageVideo, isVideoExist } from '../../../helpers/custom-validators/videos' +import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' +import { body, param } from 'express-validator/check' +import { CONSTRAINTS_FIELDS } from '../../../initializers' +import { UserRight } from '../../../../shared' +import { logger } from '../../../helpers/logger' +import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' +import { cleanUpReqFiles } from '../../../helpers/express-utils' + +const addVideoCaptionValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), + param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), + body('captionfile') + .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage( + 'This caption file is not supported or too large. Please, make sure it is of the following type : ' + + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ') + ), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking addVideoCaption parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (!await isVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req) + + // Check if the user who did the request is able to update the video + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) + + return next() + } +] + +const deleteVideoCaptionValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), + param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return + + // Check if the user who did the request is able to update the video + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return + + return next() + } +] + +const listVideoCaptionsValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listVideoCaptions parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res, 'id')) return + + return next() + } +] + +export { + addVideoCaptionValidator, + listVideoCaptionsValidator, + deleteVideoCaptionValidator +} diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts new file mode 100644 index 000000000..f039794e0 --- /dev/null +++ b/server/middlewares/validators/videos/video-channels.ts @@ -0,0 +1,175 @@ +import * as express from 'express' +import { body, param } from 'express-validator/check' +import { UserRight } from '../../../../shared' +import { isAccountNameWithHostExist } from '../../../helpers/custom-validators/accounts' +import { + isLocalVideoChannelNameExist, + isVideoChannelDescriptionValid, + isVideoChannelNameValid, + isVideoChannelNameWithHostExist, + isVideoChannelSupportValid +} from '../../../helpers/custom-validators/video-channels' +import { logger } from '../../../helpers/logger' +import { UserModel } from '../../../models/account/user' +import { VideoChannelModel } from '../../../models/video/video-channel' +import { areValidationErrors } from '../utils' +import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' +import { ActorModel } from '../../../models/activitypub/actor' + +const listVideoAccountChannelsValidator = [ + param('accountName').exists().withMessage('Should have a valid account name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listVideoAccountChannelsValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isAccountNameWithHostExist(req.params.accountName, res)) return + + return next() + } +] + +const videoChannelsAddValidator = [ + body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), + body('displayName').custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), + body('description').optional().custom(isVideoChannelDescriptionValid).withMessage('Should have a valid description'), + body('support').optional().custom(isVideoChannelSupportValid).withMessage('Should have a valid support text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoChannelsAdd parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + const actor = await ActorModel.loadLocalByName(req.body.name) + if (actor) { + res.status(409) + .send({ error: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' }) + .end() + return false + } + + return next() + } +] + +const videoChannelsUpdateValidator = [ + param('nameWithHost').exists().withMessage('Should have an video channel name with host'), + body('displayName').optional().custom(isVideoChannelNameValid).withMessage('Should have a valid display name'), + body('description').optional().custom(isVideoChannelDescriptionValid).withMessage('Should have a valid description'), + body('support').optional().custom(isVideoChannelSupportValid).withMessage('Should have a valid support text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return + + // We need to make additional checks + if (res.locals.videoChannel.Actor.isOwned() === false) { + return res.status(403) + .json({ error: 'Cannot update video channel of another server' }) + .end() + } + + if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) { + return res.status(403) + .json({ error: 'Cannot update video channel of another user' }) + .end() + } + + return next() + } +] + +const videoChannelsRemoveValidator = [ + param('nameWithHost').exists().withMessage('Should have an video channel name with host'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoChannelsRemove parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return + + if (!checkUserCanDeleteVideoChannel(res.locals.oauth.token.User, res.locals.videoChannel, res)) return + if (!await checkVideoChannelIsNotTheLastOne(res)) return + + return next() + } +] + +const videoChannelsNameWithHostValidator = [ + param('nameWithHost').exists().withMessage('Should have an video channel name with host'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoChannelsNameWithHostValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + + if (!await isVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return + + return next() + } +] + +const localVideoChannelValidator = [ + param('name').custom(isVideoChannelNameValid).withMessage('Should have a valid video channel name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking localVideoChannelValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isLocalVideoChannelNameExist(req.params.name, res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + listVideoAccountChannelsValidator, + videoChannelsAddValidator, + videoChannelsUpdateValidator, + videoChannelsRemoveValidator, + videoChannelsNameWithHostValidator, + localVideoChannelValidator +} + +// --------------------------------------------------------------------------- + +function checkUserCanDeleteVideoChannel (user: UserModel, videoChannel: VideoChannelModel, res: express.Response) { + if (videoChannel.Actor.isOwned() === false) { + res.status(403) + .json({ error: 'Cannot remove video channel of another server.' }) + .end() + + return false + } + + // Check if the user can delete the video channel + // The user can delete it if s/he is an admin + // Or if s/he is the video channel's account + if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_CHANNEL) === false && videoChannel.Account.userId !== user.id) { + res.status(403) + .json({ error: 'Cannot remove video channel of another user' }) + .end() + + return false + } + + return true +} + +async function checkVideoChannelIsNotTheLastOne (res: express.Response) { + const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id) + + if (count <= 1) { + res.status(409) + .json({ error: 'Cannot remove the last channel of this user' }) + .end() + + return false + } + + return true +} diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts new file mode 100644 index 000000000..348d33082 --- /dev/null +++ b/server/middlewares/validators/videos/video-comments.ts @@ -0,0 +1,195 @@ +import * as express from 'express' +import { body, param } from 'express-validator/check' +import { UserRight } from '../../../../shared' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { UserModel } from '../../../models/account/user' +import { VideoModel } from '../../../models/video/video' +import { VideoCommentModel } from '../../../models/video/video-comment' +import { areValidationErrors } from '../utils' + +const listVideoCommentThreadsValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res, 'only-video')) return + + return next() + } +] + +const listVideoThreadCommentsValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('threadId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res, 'only-video')) return + if (!await isVideoCommentThreadExist(req.params.threadId, res.locals.video, res)) return + + return next() + } +] + +const addVideoCommentThreadValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking addVideoCommentThread parameters.', { parameters: req.params, body: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!isVideoCommentsEnabled(res.locals.video, res)) return + + return next() + } +] + +const addVideoCommentReplyValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), + body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking addVideoCommentReply parameters.', { parameters: req.params, body: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!isVideoCommentsEnabled(res.locals.video, res)) return + if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return + + return next() + } +] + +const videoCommentGetValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res, 'id')) return + if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return + + return next() + } +] + +const removeVideoCommentValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking removeVideoCommentValidator parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return + + // Check if the user who did the request is able to delete the video + if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoComment, res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + listVideoCommentThreadsValidator, + listVideoThreadCommentsValidator, + addVideoCommentThreadValidator, + addVideoCommentReplyValidator, + videoCommentGetValidator, + removeVideoCommentValidator +} + +// --------------------------------------------------------------------------- + +async function isVideoCommentThreadExist (id: number, video: VideoModel, res: express.Response) { + const videoComment = await VideoCommentModel.loadById(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== video.id) { + res.status(400) + .json({ error: 'Video comment is associated to this video.' }) + .end() + + return false + } + + if (videoComment.inReplyToCommentId !== null) { + res.status(400) + .json({ error: 'Video comment is not a thread.' }) + .end() + + return false + } + + res.locals.videoCommentThread = videoComment + return true +} + +async function isVideoCommentExist (id: number, video: VideoModel, res: express.Response) { + const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== video.id) { + res.status(400) + .json({ error: 'Video comment is associated to this video.' }) + .end() + + return false + } + + res.locals.videoComment = videoComment + return true +} + +function isVideoCommentsEnabled (video: VideoModel, res: express.Response) { + if (video.commentsEnabled !== true) { + res.status(409) + .json({ error: 'Video comments are disabled for this video.' }) + .end() + + return false + } + + return true +} + +function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCommentModel, res: express.Response) { + const account = videoComment.Account + if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) === false && account.userId !== user.id) { + res.status(403) + .json({ error: 'Cannot remove video comment of another user' }) + .end() + return false + } + + return true +} diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts new file mode 100644 index 000000000..48d20f904 --- /dev/null +++ b/server/middlewares/validators/videos/video-imports.ts @@ -0,0 +1,75 @@ +import * as express from 'express' +import { body } from 'express-validator/check' +import { isIdValid } from '../../../helpers/custom-validators/misc' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' +import { getCommonVideoAttributes } from './videos' +import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' +import { cleanUpReqFiles } from '../../../helpers/express-utils' +import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' +import { CONFIG } from '../../../initializers/constants' +import { CONSTRAINTS_FIELDS } from '../../../initializers' + +const videoImportAddValidator = getCommonVideoAttributes().concat([ + body('channelId') + .toInt() + .custom(isIdValid).withMessage('Should have correct video channel id'), + body('targetUrl') + .optional() + .custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'), + body('magnetUri') + .optional() + .custom(isVideoMagnetUriValid).withMessage('Should have a valid video magnet URI'), + body('torrentfile') + .custom((value, { req }) => isVideoImportTorrentFile(req.files)).withMessage( + 'This torrent file is not supported or too large. Please, make sure it is of the following type: ' + + CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.EXTNAME.join(', ') + ), + body('name') + .optional() + .custom(isVideoNameValid).withMessage('Should have a valid name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body }) + + const user = res.locals.oauth.token.User + const torrentFile = req.files && req.files['torrentfile'] ? req.files['torrentfile'][0] : undefined + + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + + if (req.body.targetUrl && CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true) { + cleanUpReqFiles(req) + return res.status(409) + .json({ error: 'HTTP import is not enabled on this instance.' }) + .end() + } + + if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) { + cleanUpReqFiles(req) + return res.status(409) + .json({ error: 'Torrent/magnet URI import is not enabled on this instance.' }) + .end() + } + + if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + + // Check we have at least 1 required param + if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) { + cleanUpReqFiles(req) + + return res.status(400) + .json({ error: 'Should have a magnetUri or a targetUrl or a torrent file.' }) + .end() + } + + return next() + } +]) + +// --------------------------------------------------------------------------- + +export { + videoImportAddValidator +} + +// --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/videos/video-watch.ts b/server/middlewares/validators/videos/video-watch.ts new file mode 100644 index 000000000..bca64662f --- /dev/null +++ b/server/middlewares/validators/videos/video-watch.ts @@ -0,0 +1,28 @@ +import { body, param } from 'express-validator/check' +import * as express from 'express' +import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { areValidationErrors } from '../utils' +import { logger } from '../../../helpers/logger' + +const videoWatchingValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + body('currentTime') + .toInt() + .isInt().withMessage('Should have correct current time'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoWatching parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res, 'id')) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + videoWatchingValidator +} diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts new file mode 100644 index 000000000..d6b8aa725 --- /dev/null +++ b/server/middlewares/validators/videos/videos.ts @@ -0,0 +1,399 @@ +import * as express from 'express' +import 'express-validator' +import { body, param, ValidationChain } from 'express-validator/check' +import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' +import { + isBooleanValid, + isDateValid, + isIdOrUUIDValid, + isIdValid, + isUUIDValid, + toIntOrNull, + toValueOrNull +} from '../../../helpers/custom-validators/misc' +import { + checkUserCanManageVideo, + isScheduleVideoUpdatePrivacyValid, + isVideoCategoryValid, + isVideoChannelOfAccountExist, + isVideoDescriptionValid, + isVideoExist, + isVideoFile, + isVideoImage, + isVideoLanguageValid, + isVideoLicenceValid, + isVideoNameValid, + isVideoPrivacyValid, + isVideoRatingTypeValid, + isVideoSupportValid, + isVideoTagsValid +} from '../../../helpers/custom-validators/videos' +import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' +import { logger } from '../../../helpers/logger' +import { CONSTRAINTS_FIELDS } from '../../../initializers' +import { VideoShareModel } from '../../../models/video/video-share' +import { authenticate } from '../../oauth' +import { areValidationErrors } from '../utils' +import { cleanUpReqFiles } from '../../../helpers/express-utils' +import { VideoModel } from '../../../models/video/video' +import { UserModel } from '../../../models/account/user' +import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership' +import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' +import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' +import { AccountModel } from '../../../models/account/account' +import { VideoFetchType } from '../../../helpers/video' + +const videosAddValidator = getCommonVideoAttributes().concat([ + body('videofile') + .custom((value, { req }) => isVideoFile(req.files)).withMessage( + 'This file is not supported or too large. Please, make sure it is of the following type: ' + + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') + ), + body('name').custom(isVideoNameValid).withMessage('Should have a valid name'), + body('channelId') + .toInt() + .custom(isIdValid).withMessage('Should have correct video channel id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) + + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) + + const videoFile: Express.Multer.File = req.files['videofile'][0] + const user = res.locals.oauth.token.User + + if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + + const isAble = await user.isAbleToUploadVideo(videoFile) + if (isAble === false) { + res.status(403) + .json({ error: 'The user video quota is exceeded with this video.' }) + .end() + + return cleanUpReqFiles(req) + } + + let duration: number + + try { + duration = await getDurationFromVideoFile(videoFile.path) + } catch (err) { + logger.error('Invalid input file in videosAddValidator.', { err }) + res.status(400) + .json({ error: 'Invalid input file.' }) + .end() + + return cleanUpReqFiles(req) + } + + videoFile['duration'] = duration + + return next() + } +]) + +const videosUpdateValidator = getCommonVideoAttributes().concat([ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + body('name') + .optional() + .custom(isVideoNameValid).withMessage('Should have a valid name'), + body('channelId') + .optional() + .toInt() + .custom(isIdValid).withMessage('Should have correct video channel id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosUpdate parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) + if (!await isVideoExist(req.params.id, res)) return cleanUpReqFiles(req) + + const video = res.locals.video + + // Check if the user who did the request is able to update the video + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) + + if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) { + cleanUpReqFiles(req) + return res.status(409) + .json({ error: 'Cannot set "private" a video that was not private.' }) + .end() + } + + if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + + return next() + } +]) + +const videosCustomGetValidator = (fetchType: VideoFetchType) => { + return [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosGet parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res, fetchType)) return + + const video: VideoModel = res.locals.video + + // Video private or blacklisted + if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) { + return authenticate(req, res, () => { + const user: UserModel = res.locals.oauth.token.User + + // Only the owner or a user that have blacklist rights can see the video + if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { + return res.status(403) + .json({ error: 'Cannot get this private or blacklisted video.' }) + .end() + } + + return next() + }) + } + + // Video is public, anyone can access it + if (video.privacy === VideoPrivacy.PUBLIC) return next() + + // Video is unlisted, check we used the uuid to fetch it + if (video.privacy === VideoPrivacy.UNLISTED) { + if (isUUIDValid(req.params.id)) return next() + + // Don't leak this unlisted video + return res.status(404).end() + } + } + ] +} + +const videosGetValidator = videosCustomGetValidator('all') + +const videosRemoveValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videosRemove parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res)) return + + // Check if the user who did the request is able to delete the video + if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.REMOVE_ANY_VIDEO, res)) return + + return next() + } +] + +const videoRateValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoRate parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res)) return + + return next() + } +] + +const videosShareValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoShare parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res)) return + + const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined) + if (!share) { + return res.status(404) + .end() + } + + res.locals.videoShare = share + return next() + } +] + +const videosChangeOwnershipValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking changeOwnership parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + + // Check if the user who did the request is able to change the ownership of the video + if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return + + const nextOwner = await AccountModel.loadLocalByName(req.body.username) + if (!nextOwner) { + res.status(400) + .type('json') + .end() + return + } + res.locals.nextOwner = nextOwner + + return next() + } +] + +const videosTerminateChangeOwnershipValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking changeOwnership parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return + + // Check if the user who did the request is able to change the ownership of the video + if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return + + return next() + }, + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + + if (videoChangeOwnership.status === VideoChangeOwnershipStatus.WAITING) { + return next() + } else { + res.status(403) + .json({ error: 'Ownership already accepted or refused' }) + .end() + return + } + } +] + +const videosAcceptChangeOwnershipValidator = [ + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const body = req.body as VideoChangeOwnershipAccept + if (!await isVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return + + const user = res.locals.oauth.token.User + const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel + const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getOriginalFile()) + if (isAble === false) { + res.status(403) + .json({ error: 'The user video quota is exceeded with this video.' }) + .end() + return + } + + return next() + } +] + +function getCommonVideoAttributes () { + return [ + body('thumbnailfile') + .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( + 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') + ), + body('previewfile') + .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( + 'This preview file is not supported or too large. Please, make sure it is of the following type: ' + + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') + ), + + body('category') + .optional() + .customSanitizer(toIntOrNull) + .custom(isVideoCategoryValid).withMessage('Should have a valid category'), + body('licence') + .optional() + .customSanitizer(toIntOrNull) + .custom(isVideoLicenceValid).withMessage('Should have a valid licence'), + body('language') + .optional() + .customSanitizer(toValueOrNull) + .custom(isVideoLanguageValid).withMessage('Should have a valid language'), + body('nsfw') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'), + body('waitTranscoding') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'), + body('privacy') + .optional() + .toInt() + .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'), + body('description') + .optional() + .customSanitizer(toValueOrNull) + .custom(isVideoDescriptionValid).withMessage('Should have a valid description'), + body('support') + .optional() + .customSanitizer(toValueOrNull) + .custom(isVideoSupportValid).withMessage('Should have a valid support text'), + body('tags') + .optional() + .customSanitizer(toValueOrNull) + .custom(isVideoTagsValid).withMessage('Should have correct tags'), + body('commentsEnabled') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have comments enabled boolean'), + + body('scheduleUpdate') + .optional() + .customSanitizer(toValueOrNull), + body('scheduleUpdate.updateAt') + .optional() + .custom(isDateValid).withMessage('Should have a valid schedule update date'), + body('scheduleUpdate.privacy') + .optional() + .toInt() + .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy') + ] as (ValidationChain | express.Handler)[] +} + +// --------------------------------------------------------------------------- + +export { + videosAddValidator, + videosUpdateValidator, + videosGetValidator, + videosCustomGetValidator, + videosRemoveValidator, + videosShareValidator, + + videoRateValidator, + + videosChangeOwnershipValidator, + videosTerminateChangeOwnershipValidator, + videosAcceptChangeOwnershipValidator, + + getCommonVideoAttributes +} + +// --------------------------------------------------------------------------- + +function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) { + if (req.body.scheduleUpdate) { + if (!req.body.scheduleUpdate.updateAt) { + res.status(400) + .json({ error: 'Schedule update at is mandatory.' }) + .end() + + return true + } + } + + return false +} diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts new file mode 100644 index 000000000..0476cad9d --- /dev/null +++ b/server/models/account/user-video-history.ts @@ -0,0 +1,55 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { VideoModel } from '../video/video' +import { UserModel } from './user' + +@Table({ + tableName: 'userVideoHistory', + indexes: [ + { + fields: [ 'userId', 'videoId' ], + unique: true + }, + { + fields: [ 'userId' ] + }, + { + fields: [ 'videoId' ] + } + ] +}) +export class UserVideoHistoryModel extends Model { + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @AllowNull(false) + @IsInt + @Column + currentTime: number + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Video: VideoModel + + @ForeignKey(() => UserModel) + @Column + userId: number + + @BelongsTo(() => UserModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + User: UserModel +} diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index f23dde9b8..78972b199 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts @@ -10,6 +10,7 @@ import { getVideoLikesActivityPubUrl, getVideoSharesActivityPubUrl } from '../../lib/activitypub' +import { isArray } from 'util' export type VideoFormattingJSONOptions = { completeDescription?: boolean @@ -24,6 +25,8 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting const formattedAccount = video.VideoChannel.Account.toFormattedJSON() const formattedVideoChannel = video.VideoChannel.toFormattedJSON() + const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined + const videoObject: Video = { id: video.id, uuid: video.uuid, @@ -74,7 +77,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting url: formattedVideoChannel.url, host: formattedVideoChannel.host, avatar: formattedVideoChannel.avatar - } + }, + + userHistory: userHistory ? { + currentTime: userHistory.currentTime + } : undefined } if (options) { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 6c89c16bf..0a2d7e6de 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -92,6 +92,8 @@ import { videoModelToFormattedJSON } from './video-format-utils' import * as validator from 'validator' +import { UserVideoHistoryModel } from '../account/user-video-history' + // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation const indexes: Sequelize.DefineIndexesOptions[] = [ @@ -127,7 +129,8 @@ export enum ScopeNames { WITH_TAGS = 'WITH_TAGS', WITH_FILES = 'WITH_FILES', WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', - WITH_BLACKLISTED = 'WITH_BLACKLISTED' + WITH_BLACKLISTED = 'WITH_BLACKLISTED', + WITH_USER_HISTORY = 'WITH_USER_HISTORY' } type ForAPIOptions = { @@ -464,6 +467,8 @@ type AvailableForListIDsOptions = { include: [ { model: () => VideoFileModel.unscoped(), + // FIXME: typings + [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join required: false, include: [ { @@ -482,6 +487,20 @@ type AvailableForListIDsOptions = { required: false } ] + }, + [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => { + return { + include: [ + { + attributes: [ 'currentTime' ], + model: UserVideoHistoryModel.unscoped(), + required: false, + where: { + userId + } + } + ] + } } }) @Table({ @@ -672,11 +691,19 @@ export class VideoModel extends Model { name: 'videoId', allowNull: false }, - onDelete: 'cascade', - hooks: true + onDelete: 'cascade' }) VideoViews: VideoViewModel[] + @HasMany(() => UserVideoHistoryModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + UserVideoHistories: UserVideoHistoryModel[] + @HasOne(() => ScheduleVideoUpdateModel, { foreignKey: { name: 'videoId', @@ -930,7 +957,8 @@ export class VideoModel extends Model { accountId?: number, videoChannelId?: number, actorId?: number - trendingDays?: number + trendingDays?: number, + userId?: number }, countVideos = true) { const query: IFindOptions = { offset: options.start, @@ -961,6 +989,7 @@ export class VideoModel extends Model { accountId: options.accountId, videoChannelId: options.videoChannelId, includeLocalVideos: options.includeLocalVideos, + userId: options.userId, trendingDays } @@ -983,6 +1012,7 @@ export class VideoModel extends Model { tagsAllOf?: string[] durationMin?: number // seconds durationMax?: number // seconds + userId?: number }) { const whereAnd = [] @@ -1058,7 +1088,8 @@ export class VideoModel extends Model { licenceOneOf: options.licenceOneOf, languageOneOf: options.languageOneOf, tagsOneOf: options.tagsOneOf, - tagsAllOf: options.tagsAllOf + tagsAllOf: options.tagsAllOf, + userId: options.userId } return VideoModel.getAvailableForApi(query, queryOptions) @@ -1125,7 +1156,7 @@ export class VideoModel extends Model { return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) } - static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) { + static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { const where = VideoModel.buildWhereIdOrUUID(id) const options = { @@ -1134,14 +1165,20 @@ export class VideoModel extends Model { transaction: t } + const scopes = [ + ScopeNames.WITH_TAGS, + ScopeNames.WITH_BLACKLISTED, + ScopeNames.WITH_FILES, + ScopeNames.WITH_ACCOUNT_DETAILS, + ScopeNames.WITH_SCHEDULED_UPDATE + ] + + if (userId) { + scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings + } + return VideoModel - .scope([ - ScopeNames.WITH_TAGS, - ScopeNames.WITH_BLACKLISTED, - ScopeNames.WITH_FILES, - ScopeNames.WITH_ACCOUNT_DETAILS, - ScopeNames.WITH_SCHEDULED_UPDATE - ]) + .scope(scopes) .findOne(options) } @@ -1225,7 +1262,11 @@ export class VideoModel extends Model { return {} } - private static async getAvailableForApi (query: IFindOptions, options: AvailableForListIDsOptions, countVideos = true) { + private static async getAvailableForApi ( + query: IFindOptions, + options: AvailableForListIDsOptions & { userId?: number}, + countVideos = true + ) { const idsScope = { method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, options @@ -1249,8 +1290,15 @@ export class VideoModel extends Model { if (ids.length === 0) return { data: [], total: count } - const apiScope = { - method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] + // FIXME: typings + const apiScope: any[] = [ + { + method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] + } + ] + + if (options.userId) { + apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] }) } const secondQuery = { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 44460a167..71a217649 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -15,3 +15,4 @@ import './video-channels' import './video-comments' import './video-imports' import './videos' +import './videos-history' diff --git a/server/tests/api/check-params/videos-history.ts b/server/tests/api/check-params/videos-history.ts new file mode 100644 index 000000000..808c3b616 --- /dev/null +++ b/server/tests/api/check-params/videos-history.ts @@ -0,0 +1,79 @@ +/* tslint:disable:no-unused-expression */ + +import * as chai from 'chai' +import 'mocha' +import { + flushTests, + killallServers, + makePostBodyRequest, + makePutBodyRequest, + runServer, + ServerInfo, + setAccessTokensToServers, + uploadVideo +} from '../../utils' + +const expect = chai.expect + +describe('Test videos history API validator', function () { + let path: string + let server: ServerInfo + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + await flushTests() + + server = await runServer(1) + + await setAccessTokensToServers([ server ]) + + const res = await uploadVideo(server.url, server.accessToken, {}) + const videoUUID = res.body.video.uuid + + path = '/api/v1/videos/' + videoUUID + '/watching' + }) + + describe('When notifying a user is watching a video', function () { + + it('Should fail with an unauthenticated user', async function () { + const fields = { currentTime: 5 } + await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 }) + }) + + it('Should fail with an incorrect video id', async function () { + const fields = { currentTime: 5 } + const path = '/api/v1/videos/blabla/watching' + await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 }) + }) + + it('Should fail with an unknown video', async function () { + const fields = { currentTime: 5 } + const path = '/api/v1/videos/d91fff41-c24d-4508-8e13-3bd5902c3b02/watching' + + await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 404 }) + }) + + it('Should fail with a bad current time', async function () { + const fields = { currentTime: 'hello' } + await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 }) + }) + + it('Should succeed with the correct parameters', async function () { + const fields = { currentTime: 5 } + + await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 }) + }) + }) + + after(async function () { + killallServers([ server ]) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bf58f9c79..09bb62a8d 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -14,4 +14,5 @@ import './video-nsfw' import './video-privacy' import './video-schedule-update' import './video-transcoder' +import './videos-history' import './videos-overview' diff --git a/server/tests/api/videos/videos-history.ts b/server/tests/api/videos/videos-history.ts new file mode 100644 index 000000000..6d289b288 --- /dev/null +++ b/server/tests/api/videos/videos-history.ts @@ -0,0 +1,128 @@ +/* tslint:disable:no-unused-expression */ + +import * as chai from 'chai' +import 'mocha' +import { + flushTests, + getVideosListWithToken, + getVideoWithToken, + killallServers, makePutBodyRequest, + runServer, searchVideoWithToken, + ServerInfo, + setAccessTokensToServers, + uploadVideo +} from '../../utils' +import { Video, VideoDetails } from '../../../../shared/models/videos' +import { userWatchVideo } from '../../utils/videos/video-history' + +const expect = chai.expect + +describe('Test videos history', function () { + let server: ServerInfo = null + let video1UUID: string + let video2UUID: string + let video3UUID: string + + before(async function () { + this.timeout(30000) + + await flushTests() + + server = await runServer(1) + + await setAccessTokensToServers([ server ]) + + { + const res = await uploadVideo(server.url, server.accessToken, { name: 'video 1' }) + video1UUID = res.body.video.uuid + } + + { + const res = await uploadVideo(server.url, server.accessToken, { name: 'video 2' }) + video2UUID = res.body.video.uuid + } + + { + const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' }) + video3UUID = res.body.video.uuid + } + }) + + it('Should get videos, without watching history', async function () { + const res = await getVideosListWithToken(server.url, server.accessToken) + const videos: Video[] = res.body.data + + for (const video of videos) { + const resDetail = await getVideoWithToken(server.url, server.accessToken, video.id) + const videoDetails: VideoDetails = resDetail.body + + expect(video.userHistory).to.be.undefined + expect(videoDetails.userHistory).to.be.undefined + } + }) + + it('Should watch the first and second video', async function () { + await userWatchVideo(server.url, server.accessToken, video1UUID, 3) + await userWatchVideo(server.url, server.accessToken, video2UUID, 8) + }) + + it('Should return the correct history when listing, searching and getting videos', async function () { + const videosOfVideos: Video[][] = [] + + { + const res = await getVideosListWithToken(server.url, server.accessToken) + videosOfVideos.push(res.body.data) + } + + { + const res = await searchVideoWithToken(server.url, 'video', server.accessToken) + videosOfVideos.push(res.body.data) + } + + for (const videos of videosOfVideos) { + const video1 = videos.find(v => v.uuid === video1UUID) + const video2 = videos.find(v => v.uuid === video2UUID) + const video3 = videos.find(v => v.uuid === video3UUID) + + expect(video1.userHistory).to.not.be.undefined + expect(video1.userHistory.currentTime).to.equal(3) + + expect(video2.userHistory).to.not.be.undefined + expect(video2.userHistory.currentTime).to.equal(8) + + expect(video3.userHistory).to.be.undefined + } + + { + const resDetail = await getVideoWithToken(server.url, server.accessToken, video1UUID) + const videoDetails: VideoDetails = resDetail.body + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(3) + } + + { + const resDetail = await getVideoWithToken(server.url, server.accessToken, video2UUID) + const videoDetails: VideoDetails = resDetail.body + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(8) + } + + { + const resDetail = await getVideoWithToken(server.url, server.accessToken, video3UUID) + const videoDetails: VideoDetails = resDetail.body + + expect(videoDetails.userHistory).to.be.undefined + } + }) + + after(async function () { + killallServers([ server ]) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/utils/videos/video-history.ts b/server/tests/utils/videos/video-history.ts new file mode 100644 index 000000000..7635478f7 --- /dev/null +++ b/server/tests/utils/videos/video-history.ts @@ -0,0 +1,14 @@ +import { makePutBodyRequest } from '../requests/requests' + +function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) { + const path = '/api/v1/videos/' + videoId + '/watching' + const fields = { currentTime } + + return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 }) +} + +// --------------------------------------------------------------------------- + +export { + userWatchVideo +} -- cgit v1.2.3