Add hooks support for video download

This commit is contained in:
Chocobozzz 2021-03-23 11:54:08 +01:00
parent c0ab041c2c
commit 4bc45da342
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
7 changed files with 198 additions and 20 deletions

View File

@ -1,7 +1,9 @@
import { mapValues, pick } from 'lodash-es' 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 { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
import { AuthService, Notifier } from '@app/core' import { AuthService, HooksService, Notifier } from '@app/core'
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main' import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
@ -26,7 +28,7 @@ export class VideoDownloadComponent {
videoFileMetadataVideoStream: FileMetadata | undefined videoFileMetadataVideoStream: FileMetadata | undefined
videoFileMetadataAudioStream: FileMetadata | undefined videoFileMetadataAudioStream: FileMetadata | undefined
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
activeModal: NgbActiveModal activeModal: NgbModalRef
type: DownloadType = 'video' type: DownloadType = 'video'
@ -38,7 +40,8 @@ export class VideoDownloadComponent {
private notifier: Notifier, private notifier: Notifier,
private modalService: NgbModal, private modalService: NgbModal,
private videoService: VideoService, private videoService: VideoService,
private auth: AuthService private auth: AuthService,
private hooks: HooksService
) { ) {
this.bytesPipe = new BytesPipe() this.bytesPipe = new BytesPipe()
this.numbersPipe = new NumberFormatterPipe(this.localeId) this.numbersPipe = new NumberFormatterPipe(this.localeId)
@ -64,7 +67,12 @@ export class VideoDownloadComponent {
this.resolutionId = this.getVideoFiles()[0].resolution.id this.resolutionId = this.getVideoFiles()[0].resolution.id
this.onResolutionIdChange() this.onResolutionIdChange()
if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common')
})
} }
onClose () { onClose () {
@ -88,6 +96,7 @@ export class VideoDownloadComponent {
if (this.videoFile.metadata || !this.videoFile.metadataUrl) return if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
await this.hydrateMetadataFromMetadataUrl(this.videoFile) await this.hydrateMetadataFromMetadataUrl(this.videoFile)
if (!this.videoFile.metadata) return
this.videoFileMetadataFormat = this.videoFile this.videoFileMetadataFormat = this.videoFile
? this.getMetadataFormat(this.videoFile.metadata.format) ? this.getMetadataFormat(this.videoFile.metadata.format)
@ -201,7 +210,7 @@ export class VideoDownloadComponent {
private hydrateMetadataFromMetadataUrl (file: VideoFile) { private hydrateMetadataFromMetadataUrl (file: VideoFile) {
const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
observable.subscribe(res => file.metadata = res) .pipe(tap(res => file.metadata = res))
return observable.toPromise() return observable.toPromise()
} }

View File

@ -1,8 +1,10 @@
import * as cors from 'cors' import * as cors from 'cors'
import * as express from 'express' import * as express from 'express'
import { logger } from '@server/helpers/logger'
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' 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 { 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 { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { VideoStreamingPlaylistType } from '@shared/models' import { VideoStreamingPlaylistType } from '@shared/models'
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants' import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
@ -14,19 +16,19 @@ downloadRouter.use(cors())
downloadRouter.use( downloadRouter.use(
STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename', STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
downloadTorrent asyncMiddleware(downloadTorrent)
) )
downloadRouter.use( downloadRouter.use(
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
asyncMiddleware(videosDownloadValidator), asyncMiddleware(videosDownloadValidator),
downloadVideoFile asyncMiddleware(downloadVideoFile)
) )
downloadRouter.use( downloadRouter.use(
STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension', STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
asyncMiddleware(videosDownloadValidator), 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) const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 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) 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 video = res.locals.videoAll
const videoFile = getVideoFile(req, video.VideoFiles) const videoFile = getVideoFile(req, video.VideoFiles)
if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end() 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}`) 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 video = res.locals.videoAll
const playlist = getHLSPlaylist(video) const streamingPlaylist = getHLSPlaylist(video)
if (!playlist) return res.status(HttpStatusCode.NOT_FOUND_404).end 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() if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
const filename = `${video.name}-${videoFile.resolution}p-${playlist.getStringType()}${videoFile.extname}` const allowParameters = { video, streamingPlaylist, videoFile }
return res.download(getVideoFilePath(playlist, videoFile), filename)
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[]) { function getVideoFile (req: express.Request, files: MVideoFile[]) {
@ -76,3 +108,34 @@ function getHLSPlaylist (video: MVideoFullLight) {
return Object.assign(playlist, { Video: video }) 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
}

View File

@ -5,6 +5,7 @@ import { CONFIG } from '../../initializers/config'
import { FILES_CACHE } from '../../initializers/constants' import { FILES_CACHE } from '../../initializers/constants'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
import { MVideo, MVideoFile } from '@server/types/models'
class VideosTorrentCache extends AbstractVideoStaticFileCache <string> { class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
@ -22,7 +23,11 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename) const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
if (!file) return undefined 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) return this.loadRemoteFile(filename)
} }
@ -43,10 +48,14 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
await doRequestAndSaveToFile(remoteUrl, destPath) await doRequestAndSaveToFile(remoteUrl, destPath)
const downloadName = `${video.name}-${file.resolution}p.torrent` const downloadName = this.buildDownloadName(video, file)
return { isOwned: false, path: destPath, downloadName } return { isOwned: false, path: destPath, downloadName }
} }
private buildDownloadName (video: MVideo, file: MVideoFile) {
return `${video.name}-${file.resolution}p.torrent`
}
} }
export { export {

View File

@ -184,6 +184,32 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
return result 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 () { async function unregister () {

View File

@ -20,12 +20,14 @@ import {
getVideoThreadComments, getVideoThreadComments,
getVideoWithToken, getVideoWithToken,
installPlugin, installPlugin,
makeRawRequest,
registerUser, registerUser,
setAccessTokensToServers, setAccessTokensToServers,
setDefaultVideoChannel, setDefaultVideoChannel,
updateCustomSubConfig, updateCustomSubConfig,
updateVideo, updateVideo,
uploadVideo, uploadVideo,
uploadVideoAndGetId,
waitJobs waitJobs
} from '../../../shared/extra-utils' } from '../../../shared/extra-utils'
import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers' 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 () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View File

@ -85,8 +85,12 @@ export const clientActionHookObject = {
// Fired when the registration page is being initialized // Fired when the registration page is being initialized
'action:signup.register.init': true, 'action:signup.register.init': true,
// Fired when the modal to download a video/caption is shown
'action:modal.video-download.shown': true,
// ####### Embed hooks ####### // ####### 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 // Fired when the embed loaded the player
'action:embed.player.loaded': true 'action:embed.player.loaded': true

View File

@ -50,7 +50,11 @@ export const serverFilterHookObject = {
'filter:video.auto-blacklist.result': true, 'filter:video.auto-blacklist.result': true,
// Filter result used to check if a user can register on the instance // 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 export type ServerFilterHookName = keyof typeof serverFilterHookObject