Add user history and resume videos
authorChocobozzz <me@florianbigard.com>
Fri, 5 Oct 2018 09:15:06 +0000 (11:15 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 5 Oct 2018 09:22:38 +0000 (11:22 +0200)
48 files changed:
client/src/app/shared/video/video-thumbnail.component.html
client/src/app/shared/video/video-thumbnail.component.scss
client/src/app/shared/video/video-thumbnail.component.ts
client/src/app/shared/video/video.model.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/peertube-player.ts
client/src/assets/player/peertube-videojs-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts
server/controllers/activitypub/client.ts
server/controllers/api/search.ts
server/controllers/api/videos/captions.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/index.ts
server/controllers/api/videos/watching.ts [new file with mode: 0644]
server/helpers/custom-validators/videos.ts
server/helpers/video.ts
server/initializers/database.ts
server/lib/redis.ts
server/middlewares/cache.ts
server/middlewares/validators/index.ts
server/middlewares/validators/video-abuses.ts [deleted file]
server/middlewares/validators/video-blacklist.ts [deleted file]
server/middlewares/validators/video-captions.ts [deleted file]
server/middlewares/validators/video-channels.ts [deleted file]
server/middlewares/validators/video-comments.ts [deleted file]
server/middlewares/validators/video-imports.ts [deleted file]
server/middlewares/validators/videos.ts [deleted file]
server/middlewares/validators/videos/index.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-abuses.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-blacklist.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-captions.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-channels.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-comments.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-imports.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-watch.ts [new file with mode: 0644]
server/middlewares/validators/videos/videos.ts [new file with mode: 0644]
server/models/account/user-video-history.ts [new file with mode: 0644]
server/models/video/video-format-utils.ts
server/models/video/video.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/videos-history.ts [new file with mode: 0644]
server/tests/api/videos/index.ts
server/tests/api/videos/videos-history.ts [new file with mode: 0644]
server/tests/utils/videos/video-history.ts [new file with mode: 0644]
shared/models/users/index.ts
shared/models/users/user-watching-video.model.ts [new file with mode: 0644]
shared/models/videos/video.model.ts

index c1d45ea182b93c13ab72036e2581e7f6651321c3..d256669165b09778baf7a96196ba7c047f627c0a 100644 (file)
@@ -2,9 +2,11 @@
   [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
   class="video-thumbnail"
 >
-<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
+  <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
 
-<div class="video-thumbnail-overlay">
-  {{ video.durationLabel }}
-</div>
+  <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
+
+  <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
+    <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
+  </div>
 </a>
index 1dd8e5338042a397ff97ef5269825f6777312ecd..4772edaf0a6c3a203739c3d04399c5e8406c4a6e 100644 (file)
     }
   }
 
+  .progress-bar {
+    height: 3px;
+    width: 100%;
+    position: relative;
+    top: -3px;
+    background-color: rgba(0, 0, 0, 0.20);
+
+    div {
+      height: 100%;
+      background-color: var(--mainColor);
+    }
+  }
+
   .video-thumbnail-overlay {
     position: absolute;
     right: 5px;
index 86d8f6f742163f91fd8c1a486a7797162feeb2aa..ca43700c7c7d620eba0ce29fe3034564569eb714 100644 (file)
@@ -22,4 +22,12 @@ export class VideoThumbnailComponent {
 
     return this.video.thumbnailUrl
   }
+
+  getProgressPercent () {
+    if (!this.video.userHistory) return 0
+
+    const currentTime = this.video.userHistory.currentTime
+
+    return (currentTime / this.video.duration) * 100
+  }
 }
index 80794faa60d9f06a23b0129b961d1c39a21a77e4..b92c96450b1b4a60d99c8ca2bb97932e6f6c3ed7 100644 (file)
@@ -66,6 +66,10 @@ export class Video implements VideoServerModel {
     avatar: Avatar
   }
 
+  userHistory?: {
+    currentTime: number
+  }
+
   static buildClientUrl (videoUUID: string) {
     return '/videos/watch/' + videoUUID
   }
@@ -116,6 +120,8 @@ export class Video implements VideoServerModel {
 
     this.blacklisted = hash.blacklisted
     this.blacklistedReason = hash.blacklistedReason
+
+    this.userHistory = hash.userHistory
   }
 
   isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
index 2255a18a22e0a860455d528be05eba51c2743949..724a0bde94505cb10140b27921d13ac9d4ebad9f 100644 (file)
@@ -58,6 +58,10 @@ export class VideoService implements VideosProvider {
     return VideoService.BASE_VIDEO_URL + uuid + '/views'
   }
 
+  getUserWatchingVideoUrl (uuid: string) {
+    return VideoService.BASE_VIDEO_URL + uuid + '/watching'
+  }
+
   getVideo (uuid: string): Observable<VideoDetails> {
     return this.serverService.localeObservable
                .pipe(
index ea10b22ad021f73e8fa172e16a3e5209bbe2b1fd..c5deddf050272affacdef1bdb69789994bc6e010 100644 (file)
@@ -369,7 +369,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         )
   }
 
-  private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) {
+  private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) {
     this.video = video
 
     // Re init attributes
@@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.completeDescriptionShown = false
     this.remoteServerDown = false
 
+    let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
+    // Don't start the video if we are at the end
+    if (this.video.duration - startTime <= 1) startTime = 0
+
     if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
       const res = await this.confirmService.confirm(
         this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
@@ -414,7 +418,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       poster: this.video.previewUrl,
       startTime,
       theaterMode: true,
-      language: this.localeId
+      language: this.localeId,
+
+      userWatching: this.user ? {
+        url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
+        authorizationHeader: this.authService.getRequestHeaderValue()
+      } : undefined
     })
 
     if (this.videojsLocaleLoaded === false) {
index 1bf6c9267d7c07f6755018c7847efa29a491354b..792662b6c1f9c3cc7459ba4ca17558bd7266f95c 100644 (file)
@@ -10,7 +10,7 @@ import './webtorrent-info-button'
 import './peertube-videojs-plugin'
 import './peertube-load-progress-bar'
 import './theater-button'
-import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
+import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
 import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
 import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
 
@@ -34,10 +34,13 @@ function getVideojsOptions (options: {
   startTime: number | string
   theaterMode: boolean,
   videoCaptions: VideoJSCaption[],
+
   language?: string,
   controls?: boolean,
   muted?: boolean,
   loop?: boolean
+
+  userWatching?: UserWatching
 }) {
   const videojsOptions = {
     // We don't use text track settings for now
@@ -57,7 +60,8 @@ function getVideojsOptions (options: {
         playerElement: options.playerElement,
         videoViewUrl: options.videoViewUrl,
         videoDuration: options.videoDuration,
-        startTime: options.startTime
+        startTime: options.startTime,
+        userWatching: options.userWatching
       }
     },
     controlBar: {
index adc376e94a898215f4589284c2a1f3589f87759b..2330f476f0df03936c5f7c89cbcee5539a000c67 100644 (file)
@@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
 import { VideoFile } from '../../../../shared/models/videos/video.model'
 import { renderVideo } from './video-renderer'
 import './settings-menu-button'
-import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
 import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
 import * as CacheChunkStore from 'cache-chunk-store'
 import { PeertubeChunkStore } from './peertube-chunk-store'
@@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin {
     AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
     AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
     AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
-    BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
+    BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
+    USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
   }
 
   private readonly webtorrent = new WebTorrent({
@@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin {
   private videoViewInterval
   private torrentInfoInterval
   private autoQualityInterval
+  private userWatchingVideoInterval
   private addTorrentDelay
   private qualityObservationTimer
   private runAutoQualitySchedulerTimer
@@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin {
       this.runTorrentInfoScheduler()
       this.runViewAdd()
 
+      if (options.userWatching) this.runUserWatchVideo(options.userWatching)
+
       this.player.one('play', () => {
         // Don't run immediately scheduler, wait some seconds the TCP connections are made
         this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
@@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin {
     clearInterval(this.torrentInfoInterval)
     clearInterval(this.autoQualityInterval)
 
+    if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
+
     // Don't need to destroy renderer, video player will be destroyed
     this.flushVideoFile(this.currentVideoFile, false)
 
@@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin {
     }, 1000)
   }
 
+  private runUserWatchVideo (options: UserWatching) {
+    let lastCurrentTime = 0
+
+    this.userWatchingVideoInterval = setInterval(() => {
+      const currentTime = Math.floor(this.player.currentTime())
+
+      if (currentTime - lastCurrentTime >= 1) {
+        lastCurrentTime = currentTime
+
+        this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
+          .catch(err => console.error('Cannot notify user is watching.', err))
+      }
+    }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
+  }
+
   private clearVideoViewInterval () {
     if (this.videoViewInterval !== undefined) {
       clearInterval(this.videoViewInterval)
@@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin {
     return fetch(this.videoViewUrl, { method: 'POST' })
   }
 
+  private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
+    const body = new URLSearchParams()
+    body.append('currentTime', currentTime.toString())
+
+    const headers = new Headers({ 'Authorization': authorizationHeader })
+
+    return fetch(url, { method: 'PUT', body, headers })
+  }
+
   private fallbackToHttp (done?: Function, play = true) {
     this.disableAutoResolution(true)
 
index 993d5ee6b01ff835d33c416648b1389c58db2ba4..b117007aff7386aca34100e59b4ef2a2ee1b5db1 100644 (file)
@@ -22,6 +22,11 @@ type VideoJSCaption = {
   src: string
 }
 
+type UserWatching = {
+  url: string,
+  authorizationHeader: string
+}
+
 type PeertubePluginOptions = {
   videoFiles: VideoFile[]
   playerElement: HTMLVideoElement
@@ -30,6 +35,8 @@ type PeertubePluginOptions = {
   startTime: number | string
   autoplay: boolean,
   videoCaptions: VideoJSCaption[]
+
+  userWatching?: UserWatching
 }
 
 // videojs typings don't have some method we need
@@ -39,5 +46,6 @@ export {
   VideoJSComponentInterface,
   PeertubePluginOptions,
   videojsUntyped,
-  VideoJSCaption
+  VideoJSCaption,
+  UserWatching
 }
index 6229c44aa0b5f144a8a0016efbd4417a6edcbb10..4331861799cbcca8ac042c6e5951823a840dcb98 100644 (file)
@@ -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'
index fd4db7a543869f187a445c42ed6760b2985559cb..4be2b5ef7898a14ead15677888a756351429153f 100644 (file)
@@ -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)
 
index 4cf8de1efe96dcdd2ee32dff12292be19f623924..3ba9181891e008013c5d2e2d7901f63fa2c2c077 100644 (file)
@@ -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'
index dc25e1e859502b820692bf50e7a138be326f8cd1..4f2b4faeeeefbbd68eab9823eb47caad338d7ac9 100644 (file)
@@ -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'
index 15ef8d4580daa81fb9460727b196c9913d49a12d..6a73e13d00c64da1b16ad5c77baefb6fad89e543 100644 (file)
@@ -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 (file)
index 0000000..e8876b4
--- /dev/null
@@ -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()
+}
index 9875c68bdfc63a120f2a7aecc27e85d236a4dee8..714f7ac956c25b8dcc35f02b716830430a131e10 100644 (file)
@@ -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)
index b1577a6b0911f14614cf063d964a8fafadcfdb70..1bd21467dd7352d1e61f97030814962f8f927e13 100644 (file)
@@ -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)
 
index 4d57bf8aa10bc3c068364d1df2b65e9fbc0add07..482c03b31c2f9a259334480808aadc245de1b7c9 100644 (file)
@@ -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
index e4e4356590d665cf479b896bd51a9f7805608317..abd75d5122d7162276fb245f7c2191ab952ad19b 100644 (file)
@@ -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<string>((res, rej) => {
@@ -197,6 +211,12 @@ class Redis {
     })
   }
 
+  private deleteFieldInHash (key: string, field: string) {
+    return new Promise<void>((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<void>((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<void>((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<number>((res, rej) => {
       this.client.incr(this.prefix + key, (err, value) => {
index 1b44957d3004ac8106ea30df3620d5150e35eb53..1e00fc7316aaa674c93b251302e3038de45090dd 100644 (file)
@@ -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) => {
index 940547a3e0bd3485be7b90625c55bb01664dd7cf..17226614cd46cca8277bde280c8bad9af80c95cc 100644 (file)
@@ -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 (file)
index f15d55a..0000000
+++ /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 (file)
index 95a2b9f..0000000
+++ /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 (file)
index 51ffd7f..0000000
+++ /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 (file)
index 56a347b..0000000
+++ /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 (file)
index 6938524..0000000
+++ /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 (file)
index b2063b8..0000000
+++ /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 (file)
index 67eabe4..0000000
+++ /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 (file)
index 0000000..294783d
--- /dev/null
@@ -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 (file)
index 0000000..be26ca1
--- /dev/null
@@ -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 (file)
index 0000000..13da7ac
--- /dev/null
@@ -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 (file)
index 0000000..63d84fb
--- /dev/null
@@ -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 (file)
index 0000000..f039794
--- /dev/null
@@ -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 (file)
index 0000000..348d330
--- /dev/null
@@ -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 (file)
index 0000000..48d20f9
--- /dev/null
@@ -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 (file)
index 0000000..bca6466
--- /dev/null
@@ -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 (file)
index 0000000..d6b8aa7
--- /dev/null
@@ -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 (file)
index 0000000..0476cad
--- /dev/null
@@ -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<UserVideoHistoryModel> {
+  @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
+}
index f23dde9b874a854ac7c079f51f6ef2808808d1be..78972b199252bbba06e3d0341103cd6f31b95630 100644 (file)
@@ -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) {
index 6c89c16bff39af544ad3c3da0989921ec61519ab..0a2d7e6de1c6106adf108f41acbf97f2a900bddc 100644 (file)
@@ -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<VideoModel> {
       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<VideoModel> {
     accountId?: number,
     videoChannelId?: number,
     actorId?: number
-    trendingDays?: number
+    trendingDays?: number,
+    userId?: number
   }, countVideos = true) {
     const query: IFindOptions<VideoModel> = {
       offset: options.start,
@@ -961,6 +989,7 @@ export class VideoModel extends Model<VideoModel> {
       accountId: options.accountId,
       videoChannelId: options.videoChannelId,
       includeLocalVideos: options.includeLocalVideos,
+      userId: options.userId,
       trendingDays
     }
 
@@ -983,6 +1012,7 @@ export class VideoModel extends Model<VideoModel> {
     tagsAllOf?: string[]
     durationMin?: number // seconds
     durationMax?: number // seconds
+    userId?: number
   }) {
     const whereAnd = []
 
@@ -1058,7 +1088,8 @@ export class VideoModel extends Model<VideoModel> {
       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<VideoModel> {
     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<VideoModel> {
       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<VideoModel> {
     return {}
   }
 
-  private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) {
+  private static async getAvailableForApi (
+    query: IFindOptions<VideoModel>,
+    options: AvailableForListIDsOptions & { userId?: number},
+    countVideos = true
+  ) {
     const idsScope = {
       method: [
         ScopeNames.AVAILABLE_FOR_LIST_IDS, options
@@ -1249,8 +1290,15 @@ export class VideoModel extends Model<VideoModel> {
 
     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 = {
index 44460a1673fd6e53044ca50ecf4bae55181823b0..71a217649f6357b86187d660266464182b5f7190 100644 (file)
@@ -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 (file)
index 0000000..808c3b6
--- /dev/null
@@ -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()
+    }
+  })
+})
index bf58f9c796b0f8bedd755581bd29b13c271259af..09bb62a8dec83ea24326850a61ff2df9126bc52f 100644 (file)
@@ -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 (file)
index 0000000..6d289b2
--- /dev/null
@@ -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 (file)
index 0000000..7635478
--- /dev/null
@@ -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
+}
index 15c2f99c204f3a1dea66f19a6730020e9a3b80ef..7114741e00ae8bd114ca58c5e12f0c684581089a 100644 (file)
@@ -7,3 +7,4 @@ export * from './user-update-me.model'
 export * from './user-right.enum'
 export * from './user-role'
 export * from './user-video-quota.model'
+export * from './user-watching-video.model'
diff --git a/shared/models/users/user-watching-video.model.ts b/shared/models/users/user-watching-video.model.ts
new file mode 100644 (file)
index 0000000..c224805
--- /dev/null
@@ -0,0 +1,3 @@
+export interface UserWatchingVideo {
+  currentTime: number
+}
index b47ab1ab8c1e0a0a7ff9e3da413fe55f82701adf..4a9fa58b109792a7b4b57958a0a2eb04d3d538c8 100644 (file)
@@ -68,6 +68,10 @@ export interface Video {
 
   account: AccountAttribute
   channel: VideoChannelAttribute
+
+  userHistory?: {
+    currentTime: number
+  }
 }
 
 export interface VideoDetails extends Video {