aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.ts19
-rw-r--r--server/controllers/download.ts85
-rw-r--r--server/lib/files-cache/videos-torrent-cache.ts13
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js26
-rw-r--r--server/tests/plugins/filter-hooks.ts63
-rw-r--r--shared/models/plugins/client-hook.model.ts6
-rw-r--r--shared/models/plugins/server-hook.model.ts6
7 files changed, 198 insertions, 20 deletions
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts
index 90f4daf7c..a57e4ce6d 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -1,7 +1,9 @@
1import { mapValues, pick } from 'lodash-es' 1import { mapValues, pick } from 'lodash-es'
2import { pipe } from 'rxjs'
3import { tap } from 'rxjs/operators'
2import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 5import { AuthService, HooksService, Notifier } from '@app/core'
4import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
5import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' 7import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
6import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main' 8import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
7 9
@@ -26,7 +28,7 @@ export class VideoDownloadComponent {
26 videoFileMetadataVideoStream: FileMetadata | undefined 28 videoFileMetadataVideoStream: FileMetadata | undefined
27 videoFileMetadataAudioStream: FileMetadata | undefined 29 videoFileMetadataAudioStream: FileMetadata | undefined
28 videoCaptions: VideoCaption[] 30 videoCaptions: VideoCaption[]
29 activeModal: NgbActiveModal 31 activeModal: NgbModalRef
30 32
31 type: DownloadType = 'video' 33 type: DownloadType = 'video'
32 34
@@ -38,7 +40,8 @@ export class VideoDownloadComponent {
38 private notifier: Notifier, 40 private notifier: Notifier,
39 private modalService: NgbModal, 41 private modalService: NgbModal,
40 private videoService: VideoService, 42 private videoService: VideoService,
41 private auth: AuthService 43 private auth: AuthService,
44 private hooks: HooksService
42 ) { 45 ) {
43 this.bytesPipe = new BytesPipe() 46 this.bytesPipe = new BytesPipe()
44 this.numbersPipe = new NumberFormatterPipe(this.localeId) 47 this.numbersPipe = new NumberFormatterPipe(this.localeId)
@@ -64,7 +67,12 @@ export class VideoDownloadComponent {
64 67
65 this.resolutionId = this.getVideoFiles()[0].resolution.id 68 this.resolutionId = this.getVideoFiles()[0].resolution.id
66 this.onResolutionIdChange() 69 this.onResolutionIdChange()
70
67 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 71 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
72
73 this.activeModal.shown.subscribe(() => {
74 this.hooks.runAction('action:modal.video-download.shown', 'common')
75 })
68 } 76 }
69 77
70 onClose () { 78 onClose () {
@@ -88,6 +96,7 @@ export class VideoDownloadComponent {
88 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return 96 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
89 97
90 await this.hydrateMetadataFromMetadataUrl(this.videoFile) 98 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
99 if (!this.videoFile.metadata) return
91 100
92 this.videoFileMetadataFormat = this.videoFile 101 this.videoFileMetadataFormat = this.videoFile
93 ? this.getMetadataFormat(this.videoFile.metadata.format) 102 ? this.getMetadataFormat(this.videoFile.metadata.format)
@@ -201,7 +210,7 @@ export class VideoDownloadComponent {
201 210
202 private hydrateMetadataFromMetadataUrl (file: VideoFile) { 211 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
203 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) 212 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
204 observable.subscribe(res => file.metadata = res) 213 .pipe(tap(res => file.metadata = res))
205 214
206 return observable.toPromise() 215 return observable.toPromise()
207 } 216 }
diff --git a/server/controllers/download.ts b/server/controllers/download.ts
index 27caa1518..fd44f10e9 100644
--- a/server/controllers/download.ts
+++ b/server/controllers/download.ts
@@ -1,8 +1,10 @@
1import * as cors from 'cors' 1import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { logger } from '@server/helpers/logger'
3import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' 4import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
5import { Hooks } from '@server/lib/plugins/hooks'
4import { getVideoFilePath } from '@server/lib/video-paths' 6import { getVideoFilePath } from '@server/lib/video-paths'
5import { MVideoFile, MVideoFullLight } from '@server/types/models' 7import { MStreamingPlaylist, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 8import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
7import { VideoStreamingPlaylistType } from '@shared/models' 9import { VideoStreamingPlaylistType } from '@shared/models'
8import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants' 10import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
@@ -14,19 +16,19 @@ downloadRouter.use(cors())
14 16
15downloadRouter.use( 17downloadRouter.use(
16 STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename', 18 STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
17 downloadTorrent 19 asyncMiddleware(downloadTorrent)
18) 20)
19 21
20downloadRouter.use( 22downloadRouter.use(
21 STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', 23 STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
22 asyncMiddleware(videosDownloadValidator), 24 asyncMiddleware(videosDownloadValidator),
23 downloadVideoFile 25 asyncMiddleware(downloadVideoFile)
24) 26)
25 27
26downloadRouter.use( 28downloadRouter.use(
27 STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension', 29 STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
28 asyncMiddleware(videosDownloadValidator), 30 asyncMiddleware(videosDownloadValidator),
29 downloadHLSVideoFile 31 asyncMiddleware(downloadHLSVideoFile)
30) 32)
31 33
32// --------------------------------------------------------------------------- 34// ---------------------------------------------------------------------------
@@ -41,28 +43,58 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
41 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename) 43 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
42 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 44 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
43 45
46 const allowParameters = { torrentPath: result.path, downloadName: result.downloadName }
47
48 const allowedResult = await Hooks.wrapFun(
49 isTorrentDownloadAllowed,
50 allowParameters,
51 'filter:api.download.torrent.allowed.result'
52 )
53
54 if (!checkAllowResult(res, allowParameters, allowedResult)) return
55
44 return res.download(result.path, result.downloadName) 56 return res.download(result.path, result.downloadName)
45} 57}
46 58
47function downloadVideoFile (req: express.Request, res: express.Response) { 59async function downloadVideoFile (req: express.Request, res: express.Response) {
48 const video = res.locals.videoAll 60 const video = res.locals.videoAll
49 61
50 const videoFile = getVideoFile(req, video.VideoFiles) 62 const videoFile = getVideoFile(req, video.VideoFiles)
51 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end() 63 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
52 64
65 const allowParameters = { video, videoFile }
66
67 const allowedResult = await Hooks.wrapFun(
68 isVideoDownloadAllowed,
69 allowParameters,
70 'filter:api.download.video.allowed.result'
71 )
72
73 if (!checkAllowResult(res, allowParameters, allowedResult)) return
74
53 return res.download(getVideoFilePath(video, videoFile), `${video.name}-${videoFile.resolution}p${videoFile.extname}`) 75 return res.download(getVideoFilePath(video, videoFile), `${video.name}-${videoFile.resolution}p${videoFile.extname}`)
54} 76}
55 77
56function downloadHLSVideoFile (req: express.Request, res: express.Response) { 78async function downloadHLSVideoFile (req: express.Request, res: express.Response) {
57 const video = res.locals.videoAll 79 const video = res.locals.videoAll
58 const playlist = getHLSPlaylist(video) 80 const streamingPlaylist = getHLSPlaylist(video)
59 if (!playlist) return res.status(HttpStatusCode.NOT_FOUND_404).end 81 if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end
60 82
61 const videoFile = getVideoFile(req, playlist.VideoFiles) 83 const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
62 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end() 84 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
63 85
64 const filename = `${video.name}-${videoFile.resolution}p-${playlist.getStringType()}${videoFile.extname}` 86 const allowParameters = { video, streamingPlaylist, videoFile }
65 return res.download(getVideoFilePath(playlist, videoFile), filename) 87
88 const allowedResult = await Hooks.wrapFun(
89 isVideoDownloadAllowed,
90 allowParameters,
91 'filter:api.download.video.allowed.result'
92 )
93
94 if (!checkAllowResult(res, allowParameters, allowedResult)) return
95
96 const filename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
97 return res.download(getVideoFilePath(streamingPlaylist, videoFile), filename)
66} 98}
67 99
68function getVideoFile (req: express.Request, files: MVideoFile[]) { 100function getVideoFile (req: express.Request, files: MVideoFile[]) {
@@ -76,3 +108,34 @@ function getHLSPlaylist (video: MVideoFullLight) {
76 108
77 return Object.assign(playlist, { Video: video }) 109 return Object.assign(playlist, { Video: video })
78} 110}
111
112type AllowedResult = {
113 allowed: boolean
114 errorMessage?: string
115}
116
117function isTorrentDownloadAllowed (_object: {
118 torrentPath: string
119}): AllowedResult {
120 return { allowed: true }
121}
122
123function isVideoDownloadAllowed (_object: {
124 video: MVideo
125 videoFile: MVideoFile
126 streamingPlaylist?: MStreamingPlaylist
127}): AllowedResult {
128 return { allowed: true }
129}
130
131function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
132 if (!result || result.allowed !== true) {
133 logger.info('Download is not allowed.', { result, allowParameters })
134 res.status(HttpStatusCode.FORBIDDEN_403)
135 .json({ error: result.errorMessage || 'Refused download' })
136
137 return false
138 }
139
140 return true
141}
diff --git a/server/lib/files-cache/videos-torrent-cache.ts b/server/lib/files-cache/videos-torrent-cache.ts
index 881fa9ced..23217f140 100644
--- a/server/lib/files-cache/videos-torrent-cache.ts
+++ b/server/lib/files-cache/videos-torrent-cache.ts
@@ -5,6 +5,7 @@ import { CONFIG } from '../../initializers/config'
5import { FILES_CACHE } from '../../initializers/constants' 5import { FILES_CACHE } from '../../initializers/constants'
6import { VideoModel } from '../../models/video/video' 6import { VideoModel } from '../../models/video/video'
7import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' 7import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
8import { MVideo, MVideoFile } from '@server/types/models'
8 9
9class VideosTorrentCache extends AbstractVideoStaticFileCache <string> { 10class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
10 11
@@ -22,7 +23,11 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
22 const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename) 23 const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
23 if (!file) return undefined 24 if (!file) return undefined
24 25
25 if (file.getVideo().isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename) } 26 if (file.getVideo().isOwned()) {
27 const downloadName = this.buildDownloadName(file.getVideo(), file)
28
29 return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
30 }
26 31
27 return this.loadRemoteFile(filename) 32 return this.loadRemoteFile(filename)
28 } 33 }
@@ -43,10 +48,14 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
43 48
44 await doRequestAndSaveToFile(remoteUrl, destPath) 49 await doRequestAndSaveToFile(remoteUrl, destPath)
45 50
46 const downloadName = `${video.name}-${file.resolution}p.torrent` 51 const downloadName = this.buildDownloadName(video, file)
47 52
48 return { isOwned: false, path: destPath, downloadName } 53 return { isOwned: false, path: destPath, downloadName }
49 } 54 }
55
56 private buildDownloadName (video: MVideo, file: MVideoFile) {
57 return `${video.name}-${file.resolution}p.torrent`
58 }
50} 59}
51 60
52export { 61export {
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 305d92002..9913d0846 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -184,6 +184,32 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
184 return result 184 return result
185 } 185 }
186 }) 186 })
187
188 registerHook({
189 target: 'filter:api.download.torrent.allowed.result',
190 handler: (result, params) => {
191 if (params && params.downloadName.includes('bad torrent')) {
192 return { allowed: false, errorMessage: 'Liu Bei' }
193 }
194
195 return result
196 }
197 })
198
199 registerHook({
200 target: 'filter:api.download.video.allowed.result',
201 handler: (result, params) => {
202 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
203 return { allowed: false, errorMessage: 'Cao Cao' }
204 }
205
206 if (params && params.streamingPlaylist && params.video.name.includes('bad playlist file')) {
207 return { allowed: false, errorMessage: 'Sun Jian' }
208 }
209
210 return result
211 }
212 })
187} 213}
188 214
189async function unregister () { 215async function unregister () {
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index d88170201..6996ae788 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -20,12 +20,14 @@ import {
20 getVideoThreadComments, 20 getVideoThreadComments,
21 getVideoWithToken, 21 getVideoWithToken,
22 installPlugin, 22 installPlugin,
23 makeRawRequest,
23 registerUser, 24 registerUser,
24 setAccessTokensToServers, 25 setAccessTokensToServers,
25 setDefaultVideoChannel, 26 setDefaultVideoChannel,
26 updateCustomSubConfig, 27 updateCustomSubConfig,
27 updateVideo, 28 updateVideo,
28 uploadVideo, 29 uploadVideo,
30 uploadVideoAndGetId,
29 waitJobs 31 waitJobs
30} from '../../../shared/extra-utils' 32} from '../../../shared/extra-utils'
31import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers' 33import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers'
@@ -355,6 +357,67 @@ describe('Test plugin filter hooks', function () {
355 }) 357 })
356 }) 358 })
357 359
360 describe('Download hooks', function () {
361 const downloadVideos: VideoDetails[] = []
362
363 before(async function () {
364 this.timeout(60000)
365
366 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
367 transcoding: {
368 webtorrent: {
369 enabled: true
370 },
371 hls: {
372 enabled: true
373 }
374 }
375 })
376
377 const uuids: string[] = []
378
379 for (const name of [ 'bad torrent', 'bad file', 'bad playlist file' ]) {
380 const uuid = (await uploadVideoAndGetId({ server: servers[0], videoName: name })).uuid
381 uuids.push(uuid)
382 }
383
384 await waitJobs(servers)
385
386 for (const uuid of uuids) {
387 const res = await getVideo(servers[0].url, uuid)
388 downloadVideos.push(res.body)
389 }
390 })
391
392 it('Should run filter:api.download.torrent.allowed.result', async function () {
393 const res = await makeRawRequest(downloadVideos[0].files[0].torrentDownloadUrl, 403)
394 expect(res.body.error).to.equal('Liu Bei')
395
396 await makeRawRequest(downloadVideos[1].files[0].torrentDownloadUrl, 200)
397 await makeRawRequest(downloadVideos[2].files[0].torrentDownloadUrl, 200)
398 })
399
400 it('Should run filter:api.download.video.allowed.result', async function () {
401 {
402 const res = await makeRawRequest(downloadVideos[1].files[0].fileDownloadUrl, 403)
403 expect(res.body.error).to.equal('Cao Cao')
404
405 await makeRawRequest(downloadVideos[0].files[0].fileDownloadUrl, 200)
406 await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
407 }
408
409 {
410 const res = await makeRawRequest(downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl, 403)
411 expect(res.body.error).to.equal('Sun Jian')
412
413 await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
414
415 await makeRawRequest(downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
416 await makeRawRequest(downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
417 }
418 })
419 })
420
358 after(async function () { 421 after(async function () {
359 await cleanupTests(servers) 422 await cleanupTests(servers)
360 }) 423 })
diff --git a/shared/models/plugins/client-hook.model.ts b/shared/models/plugins/client-hook.model.ts
index 7b7144676..19622e09e 100644
--- a/shared/models/plugins/client-hook.model.ts
+++ b/shared/models/plugins/client-hook.model.ts
@@ -85,8 +85,12 @@ export const clientActionHookObject = {
85 // Fired when the registration page is being initialized 85 // Fired when the registration page is being initialized
86 'action:signup.register.init': true, 86 'action:signup.register.init': true,
87 87
88 // Fired when the modal to download a video/caption is shown
89 'action:modal.video-download.shown': true,
90
88 // ####### Embed hooks ####### 91 // ####### Embed hooks #######
89 // In embed scope, peertube helpers are not available 92 // /!\ In embed scope, peertube helpers are not available
93 // ###########################
90 94
91 // Fired when the embed loaded the player 95 // Fired when the embed loaded the player
92 'action:embed.player.loaded': true 96 'action:embed.player.loaded': true
diff --git a/shared/models/plugins/server-hook.model.ts b/shared/models/plugins/server-hook.model.ts
index 082b4b591..1f7806d0d 100644
--- a/shared/models/plugins/server-hook.model.ts
+++ b/shared/models/plugins/server-hook.model.ts
@@ -50,7 +50,11 @@ export const serverFilterHookObject = {
50 'filter:video.auto-blacklist.result': true, 50 'filter:video.auto-blacklist.result': true,
51 51
52 // Filter result used to check if a user can register on the instance 52 // Filter result used to check if a user can register on the instance
53 'filter:api.user.signup.allowed.result': true 53 'filter:api.user.signup.allowed.result': true,
54
55 // Filter result used to check if video/torrent download is allowed
56 'filter:api.download.video.allowed.result': true,
57 'filter:api.download.torrent.allowed.result': true
54} 58}
55 59
56export type ServerFilterHookName = keyof typeof serverFilterHookObject 60export type ServerFilterHookName = keyof typeof serverFilterHookObject