]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add hooks support for video download
authorChocobozzz <me@florianbigard.com>
Tue, 23 Mar 2021 10:54:08 +0000 (11:54 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 24 Mar 2021 17:18:41 +0000 (18:18 +0100)
client/src/app/shared/shared-video-miniature/video-download.component.ts
server/controllers/download.ts
server/lib/files-cache/videos-torrent-cache.ts
server/tests/fixtures/peertube-plugin-test/main.js
server/tests/plugins/filter-hooks.ts
shared/models/plugins/client-hook.model.ts
shared/models/plugins/server-hook.model.ts

index 90f4daf7c9d80ab4986b52966eb5822c80292dcd..a57e4ce6d9dc6f670de5aaefbd0b702ed15c180a 100644 (file)
@@ -1,7 +1,9 @@
 import { mapValues, pick } from 'lodash-es'
+import { pipe } from 'rxjs'
+import { tap } from 'rxjs/operators'
 import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
-import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { AuthService, HooksService, Notifier } from '@app/core'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
 import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
 import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
 
@@ -26,7 +28,7 @@ export class VideoDownloadComponent {
   videoFileMetadataVideoStream: FileMetadata | undefined
   videoFileMetadataAudioStream: FileMetadata | undefined
   videoCaptions: VideoCaption[]
-  activeModal: NgbActiveModal
+  activeModal: NgbModalRef
 
   type: DownloadType = 'video'
 
@@ -38,7 +40,8 @@ export class VideoDownloadComponent {
     private notifier: Notifier,
     private modalService: NgbModal,
     private videoService: VideoService,
-    private auth: AuthService
+    private auth: AuthService,
+    private hooks: HooksService
   ) {
     this.bytesPipe = new BytesPipe()
     this.numbersPipe = new NumberFormatterPipe(this.localeId)
@@ -64,7 +67,12 @@ export class VideoDownloadComponent {
 
     this.resolutionId = this.getVideoFiles()[0].resolution.id
     this.onResolutionIdChange()
+
     if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
+
+    this.activeModal.shown.subscribe(() => {
+      this.hooks.runAction('action:modal.video-download.shown', 'common')
+    })
   }
 
   onClose () {
@@ -88,6 +96,7 @@ export class VideoDownloadComponent {
     if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
 
     await this.hydrateMetadataFromMetadataUrl(this.videoFile)
+    if (!this.videoFile.metadata) return
 
     this.videoFileMetadataFormat = this.videoFile
       ? this.getMetadataFormat(this.videoFile.metadata.format)
@@ -201,7 +210,7 @@ export class VideoDownloadComponent {
 
   private hydrateMetadataFromMetadataUrl (file: VideoFile) {
     const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
-    observable.subscribe(res => file.metadata = res)
+      .pipe(tap(res => file.metadata = res))
 
     return observable.toPromise()
   }
index 27caa15189a47685219df0fddf3bdd743e38fe5e..fd44f10e91c2e4fe3d2c8882cd246b3c531b32db 100644 (file)
@@ -1,8 +1,10 @@
 import * as cors from 'cors'
 import * as express from 'express'
+import { logger } from '@server/helpers/logger'
 import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
+import { Hooks } from '@server/lib/plugins/hooks'
 import { getVideoFilePath } from '@server/lib/video-paths'
-import { MVideoFile, MVideoFullLight } from '@server/types/models'
+import { MStreamingPlaylist, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
 import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
 import { VideoStreamingPlaylistType } from '@shared/models'
 import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
@@ -14,19 +16,19 @@ downloadRouter.use(cors())
 
 downloadRouter.use(
   STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
-  downloadTorrent
+  asyncMiddleware(downloadTorrent)
 )
 
 downloadRouter.use(
   STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
   asyncMiddleware(videosDownloadValidator),
-  downloadVideoFile
+  asyncMiddleware(downloadVideoFile)
 )
 
 downloadRouter.use(
   STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
   asyncMiddleware(videosDownloadValidator),
-  downloadHLSVideoFile
+  asyncMiddleware(downloadHLSVideoFile)
 )
 
 // ---------------------------------------------------------------------------
@@ -41,28 +43,58 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
   const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
   if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
 
+  const allowParameters = { torrentPath: result.path, downloadName: result.downloadName }
+
+  const allowedResult = await Hooks.wrapFun(
+    isTorrentDownloadAllowed,
+    allowParameters,
+    'filter:api.download.torrent.allowed.result'
+  )
+
+  if (!checkAllowResult(res, allowParameters, allowedResult)) return
+
   return res.download(result.path, result.downloadName)
 }
 
-function downloadVideoFile (req: express.Request, res: express.Response) {
+async function downloadVideoFile (req: express.Request, res: express.Response) {
   const video = res.locals.videoAll
 
   const videoFile = getVideoFile(req, video.VideoFiles)
   if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
 
+  const allowParameters = { video, videoFile }
+
+  const allowedResult = await Hooks.wrapFun(
+    isVideoDownloadAllowed,
+    allowParameters,
+    'filter:api.download.video.allowed.result'
+  )
+
+  if (!checkAllowResult(res, allowParameters, allowedResult)) return
+
   return res.download(getVideoFilePath(video, videoFile), `${video.name}-${videoFile.resolution}p${videoFile.extname}`)
 }
 
-function downloadHLSVideoFile (req: express.Request, res: express.Response) {
+async function downloadHLSVideoFile (req: express.Request, res: express.Response) {
   const video = res.locals.videoAll
-  const playlist = getHLSPlaylist(video)
-  if (!playlist) return res.status(HttpStatusCode.NOT_FOUND_404).end
+  const streamingPlaylist = getHLSPlaylist(video)
+  if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end
 
-  const videoFile = getVideoFile(req, playlist.VideoFiles)
+  const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
   if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
 
-  const filename = `${video.name}-${videoFile.resolution}p-${playlist.getStringType()}${videoFile.extname}`
-  return res.download(getVideoFilePath(playlist, videoFile), filename)
+  const allowParameters = { video, streamingPlaylist, videoFile }
+
+  const allowedResult = await Hooks.wrapFun(
+    isVideoDownloadAllowed,
+    allowParameters,
+    'filter:api.download.video.allowed.result'
+  )
+
+  if (!checkAllowResult(res, allowParameters, allowedResult)) return
+
+  const filename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
+  return res.download(getVideoFilePath(streamingPlaylist, videoFile), filename)
 }
 
 function getVideoFile (req: express.Request, files: MVideoFile[]) {
@@ -76,3 +108,34 @@ function getHLSPlaylist (video: MVideoFullLight) {
 
   return Object.assign(playlist, { Video: video })
 }
+
+type AllowedResult = {
+  allowed: boolean
+  errorMessage?: string
+}
+
+function isTorrentDownloadAllowed (_object: {
+  torrentPath: string
+}): AllowedResult {
+  return { allowed: true }
+}
+
+function isVideoDownloadAllowed (_object: {
+  video: MVideo
+  videoFile: MVideoFile
+  streamingPlaylist?: MStreamingPlaylist
+}): AllowedResult {
+  return { allowed: true }
+}
+
+function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
+  if (!result || result.allowed !== true) {
+    logger.info('Download is not allowed.', { result, allowParameters })
+    res.status(HttpStatusCode.FORBIDDEN_403)
+       .json({ error: result.errorMessage || 'Refused download' })
+
+    return false
+  }
+
+  return true
+}
index 881fa9cedf374b16cab927e7b423a17cc41e3726..23217f1403119acfdb1f5b943c51f73260eb0fd4 100644 (file)
@@ -5,6 +5,7 @@ import { CONFIG } from '../../initializers/config'
 import { FILES_CACHE } from '../../initializers/constants'
 import { VideoModel } from '../../models/video/video'
 import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
+import { MVideo, MVideoFile } from '@server/types/models'
 
 class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
 
@@ -22,7 +23,11 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
     const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
     if (!file) return undefined
 
-    if (file.getVideo().isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename) }
+    if (file.getVideo().isOwned()) {
+      const downloadName = this.buildDownloadName(file.getVideo(), file)
+
+      return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
+    }
 
     return this.loadRemoteFile(filename)
   }
@@ -43,10 +48,14 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
 
     await doRequestAndSaveToFile(remoteUrl, destPath)
 
-    const downloadName = `${video.name}-${file.resolution}p.torrent`
+    const downloadName = this.buildDownloadName(video, file)
 
     return { isOwned: false, path: destPath, downloadName }
   }
+
+  private buildDownloadName (video: MVideo, file: MVideoFile) {
+    return `${video.name}-${file.resolution}p.torrent`
+  }
 }
 
 export {
index 305d9200218686ef2e3f652d613f23e67cfdfa95..9913d0846dc18dc8e428b8bcc712628addfa95d6 100644 (file)
@@ -184,6 +184,32 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
       return result
     }
   })
+
+  registerHook({
+    target: 'filter:api.download.torrent.allowed.result',
+    handler: (result, params) => {
+      if (params && params.downloadName.includes('bad torrent')) {
+        return { allowed: false, errorMessage: 'Liu Bei' }
+      }
+
+      return result
+    }
+  })
+
+  registerHook({
+    target: 'filter:api.download.video.allowed.result',
+    handler: (result, params) => {
+      if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
+        return { allowed: false, errorMessage: 'Cao Cao' }
+      }
+
+      if (params && params.streamingPlaylist && params.video.name.includes('bad playlist file')) {
+        return { allowed: false, errorMessage: 'Sun Jian' }
+      }
+
+      return result
+    }
+  })
 }
 
 async function unregister () {
index d88170201d7df69f55fb4c86b0f239f02ecbe052..6996ae7882fb61f220e8e5e57df3b799f85d00b1 100644 (file)
@@ -20,12 +20,14 @@ import {
   getVideoThreadComments,
   getVideoWithToken,
   installPlugin,
+  makeRawRequest,
   registerUser,
   setAccessTokensToServers,
   setDefaultVideoChannel,
   updateCustomSubConfig,
   updateVideo,
   uploadVideo,
+  uploadVideoAndGetId,
   waitJobs
 } from '../../../shared/extra-utils'
 import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers'
@@ -355,6 +357,67 @@ describe('Test plugin filter hooks', function () {
     })
   })
 
+  describe('Download hooks', function () {
+    const downloadVideos: VideoDetails[] = []
+
+    before(async function () {
+      this.timeout(60000)
+
+      await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
+        transcoding: {
+          webtorrent: {
+            enabled: true
+          },
+          hls: {
+            enabled: true
+          }
+        }
+      })
+
+      const uuids: string[] = []
+
+      for (const name of [ 'bad torrent', 'bad file', 'bad playlist file' ]) {
+        const uuid = (await uploadVideoAndGetId({ server: servers[0], videoName: name })).uuid
+        uuids.push(uuid)
+      }
+
+      await waitJobs(servers)
+
+      for (const uuid of uuids) {
+        const res = await getVideo(servers[0].url, uuid)
+        downloadVideos.push(res.body)
+      }
+    })
+
+    it('Should run filter:api.download.torrent.allowed.result', async function () {
+      const res = await makeRawRequest(downloadVideos[0].files[0].torrentDownloadUrl, 403)
+      expect(res.body.error).to.equal('Liu Bei')
+
+      await makeRawRequest(downloadVideos[1].files[0].torrentDownloadUrl, 200)
+      await makeRawRequest(downloadVideos[2].files[0].torrentDownloadUrl, 200)
+    })
+
+    it('Should run filter:api.download.video.allowed.result', async function () {
+      {
+        const res = await makeRawRequest(downloadVideos[1].files[0].fileDownloadUrl, 403)
+        expect(res.body.error).to.equal('Cao Cao')
+
+        await makeRawRequest(downloadVideos[0].files[0].fileDownloadUrl, 200)
+        await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
+      }
+
+      {
+        const res = await makeRawRequest(downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl, 403)
+        expect(res.body.error).to.equal('Sun Jian')
+
+        await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
+
+        await makeRawRequest(downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
+        await makeRawRequest(downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
+      }
+    })
+  })
+
   after(async function () {
     await cleanupTests(servers)
   })
index 7b7144676f2097a685372774d62efbed4392be95..19622e09e91394075cca7877b922dca9f6699db3 100644 (file)
@@ -85,8 +85,12 @@ export const clientActionHookObject = {
   // Fired when the registration page is being initialized
   'action:signup.register.init': true,
 
+  // Fired when the modal to download a video/caption is shown
+  'action:modal.video-download.shown': true,
+
   // ####### Embed hooks #######
-  // In embed scope, peertube helpers are not available
+  // /!\ In embed scope, peertube helpers are not available
+  // ###########################
 
   // Fired when the embed loaded the player
   'action:embed.player.loaded': true
index 082b4b59184fa620c3a6a6edbe3bb8c992bcc8a7..1f7806d0db645c2ecc5b874f7d0e9b9eb18119b5 100644 (file)
@@ -50,7 +50,11 @@ export const serverFilterHookObject = {
   'filter:video.auto-blacklist.result': true,
 
   // Filter result used to check if a user can register on the instance
-  'filter:api.user.signup.allowed.result': true
+  'filter:api.user.signup.allowed.result': true,
+
+  // Filter result used to check if video/torrent download is allowed
+  'filter:api.download.video.allowed.result': true,
+  'filter:api.download.torrent.allowed.result': true
 }
 
 export type ServerFilterHookName = keyof typeof serverFilterHookObject