]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Support reinjecting token in private m3u8 playlist
authorChocobozzz <me@florianbigard.com>
Fri, 2 Dec 2022 13:47:21 +0000 (14:47 +0100)
committerChocobozzz <me@florianbigard.com>
Fri, 2 Dec 2022 14:25:20 +0000 (15:25 +0100)
22 files changed:
client/e2e/src/suites-all/private-videos.e2e-spec.ts
client/e2e/src/utils/urls.ts
client/src/assets/player/shared/manager-options/hls-options-builder.ts
client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
client/src/assets/player/types/peertube-videojs-typings.ts
server/controllers/object-storage-proxy.ts
server/controllers/shared/m3u8-playlist.ts [new file with mode: 0644]
server/controllers/static.ts
server/helpers/stream-replacer.ts [new file with mode: 0644]
server/lib/hls.ts
server/middlewares/validators/static.ts
server/middlewares/validators/users.ts
server/tests/api/object-storage/video-static-file-privacy.ts
server/tests/api/videos/video-static-file-privacy.ts
server/tests/shared/checks.ts
server/tests/shared/index.ts
server/tests/shared/streaming-playlists.ts
server/tests/shared/video-playlists.ts [moved from server/tests/shared/playlists.ts with 100% similarity]
shared/core-utils/common/url.ts
shared/server-commands/videos/streaming-playlists-command.ts
support/doc/api/openapi.yaml

index db35546596f646934aa43191b7aed85810b2248d..a25208bb3a8a43ded45da8a74973ef1f2ba52cad 100644 (file)
@@ -15,6 +15,7 @@ describe('Private videos all workflow', () => {
   let playerPage: PlayerPage
 
   const internalVideoName = 'Internal E2E test'
+  const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
 
   beforeEach(async () => {
     videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
@@ -44,6 +45,13 @@ describe('Private videos all workflow', () => {
     await checkCorrectlyPlay(playerPage)
   })
 
+  it('Should play an internal HLS only video', async () => {
+    await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
+
+    await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
+    await checkCorrectlyPlay(playerPage)
+  })
+
   it('Should play an internal WebTorrent video in embed', async () => {
     await go(FIXTURE_URLS.INTERNAL_EMBED_WEBTORRENT_VIDEO)
 
@@ -57,4 +65,11 @@ describe('Private videos all workflow', () => {
     await videoWatchPage.waitEmbedForDisplayed()
     await checkCorrectlyPlay(playerPage)
   })
+
+  it('Should play an internal HLS only video in embed', async () => {
+    await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
+
+    await videoWatchPage.waitEmbedForDisplayed()
+    await checkCorrectlyPlay(playerPage)
+  })
 })
index f91d9a048bb751c3a5c4444776d0e4ce960a3fba..cc0bdfbff32a45a8caaa76206cab4885473dde84 100644 (file)
@@ -1,9 +1,13 @@
 const FIXTURE_URLS = {
   INTERNAL_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
   INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
+
   INTERNAL_EMBED_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
   INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
 
+  INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
+  INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
+
   WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
 
   HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
index 497a974363c769280c5e27f1a14cafd57f839e7b..63e9fa8c8c3173ff613d5aa4edb4190a45ae8135 100644 (file)
@@ -32,6 +32,7 @@ export class HLSOptionsBuilder {
 
     const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
       requiresAuth: commonOptions.requiresAuth,
+      videoFileToken: commonOptions.videoFileToken,
 
       redundancyUrlManager,
       type: 'application/x-mpegURL',
index b608ee3e230c24968ae1a68e1f9b24ae7d1ff866..e6f525fea4ea759c803b5c6de6a323c116303c93 100644 (file)
@@ -3,7 +3,7 @@ import videojs from 'video.js'
 import { Events, Segment } from '@peertube/p2p-media-loader-core'
 import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
 import { logger } from '@root-helpers/logger'
-import { timeToInt } from '@shared/core-utils'
+import { addQueryParams, timeToInt } from '@shared/core-utils'
 import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
 import { registerConfigPlugin, registerSourceHandler } from './hls-plugin'
 
@@ -39,46 +39,37 @@ class P2pMediaLoaderPlugin extends Plugin {
     super(player)
 
     this.options = options
+    this.startTime = timeToInt(options.startTime)
 
     // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
     if (!(videojs as any).Html5Hlsjs) {
-      logger.warn('HLS.js does not seem to be supported. Try to fallback to built in HLS.')
-
-      let message: string
-      if (!player.canPlayType('application/vnd.apple.mpegurl')) {
-        message = 'Cannot fallback to built-in HLS'
-      } else if (options.requiresAuth) {
-        message = 'Video requires auth which is not compatible to build-in HLS player'
+      if (player.canPlayType('application/vnd.apple.mpegurl')) {
+        this.fallbackToBuiltInIOS()
+        return
       }
 
-      if (message) {
-        logger.warn(message)
+      const message = 'HLS.js does not seem to be supported. Cannot fallback to built-in HLS'
+      logger.warn(message)
 
-        const error: MediaError = {
-          code: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
-          message,
-          MEDIA_ERR_ABORTED: MediaError.MEDIA_ERR_ABORTED,
-          MEDIA_ERR_DECODE: MediaError.MEDIA_ERR_DECODE,
-          MEDIA_ERR_NETWORK: MediaError.MEDIA_ERR_NETWORK,
-          MEDIA_ERR_SRC_NOT_SUPPORTED: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
-        }
-
-        player.ready(() => player.error(error))
-        return
+      const error: MediaError = {
+        code: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
+        message,
+        MEDIA_ERR_ABORTED: MediaError.MEDIA_ERR_ABORTED,
+        MEDIA_ERR_DECODE: MediaError.MEDIA_ERR_DECODE,
+        MEDIA_ERR_NETWORK: MediaError.MEDIA_ERR_NETWORK,
+        MEDIA_ERR_SRC_NOT_SUPPORTED: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
       }
 
-      // Workaround to force video.js to not re create a video element
-      (this.player as any).playerElIngest_ = this.player.el().parentNode
-    } else {
-      // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
-      (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
-        this.hlsjs = hlsjs
-      })
-
-      initVideoJsContribHlsJsPlayer(player)
+      player.ready(() => player.error(error))
+      return
     }
 
-    this.startTime = timeToInt(options.startTime)
+    // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
+    (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (_videojsPlayer: any, hlsjs: any) => {
+      this.hlsjs = hlsjs
+    })
+
+    initVideoJsContribHlsJsPlayer(player)
 
     player.src({
       type: options.type,
@@ -88,9 +79,7 @@ class P2pMediaLoaderPlugin extends Plugin {
     player.ready(() => {
       this.initializeCore()
 
-      if ((videojs as any).Html5Hlsjs) {
-        this.initializePlugin()
-      }
+      this.initializePlugin()
     })
   }
 
@@ -199,6 +188,25 @@ class P2pMediaLoaderPlugin extends Plugin {
   private arraySum (data: number[]) {
     return data.reduce((a: number, b: number) => a + b, 0)
   }
+
+  private fallbackToBuiltInIOS () {
+    logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.');
+
+    // Workaround to force video.js to not re create a video element
+    (this.player as any).playerElIngest_ = this.player.el().parentNode
+
+    this.player.src({
+      type: this.options.type,
+      src: addQueryParams(this.options.src, {
+        videoFileToken: this.options.videoFileToken(),
+        reinjectVideoFileToken: 'true'
+      })
+    })
+
+    this.player.ready(() => {
+      this.initializeCore()
+    })
+  }
 }
 
 videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
index a7ee9195040e6eb724c537d3d499334978cc419c..3c76d63f78293b9a31408e50b8ee1433ffb08c2e 100644 (file)
@@ -2,6 +2,7 @@ import { basename } from 'path'
 import { Segment } from '@peertube/p2p-media-loader-core'
 import { logger } from '@root-helpers/logger'
 import { wait } from '@root-helpers/utils'
+import { removeQueryParams } from '@shared/core-utils'
 import { isSameOrigin } from '../common'
 
 type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
@@ -24,7 +25,7 @@ function segmentValidatorFactory (options: {
     // Wait for hash generation from the server
     if (isLive) await wait(1000)
 
-    const filename = basename(segment.url)
+    const filename = basename(removeQueryParams(segment.url))
 
     const segmentValue = (await segmentsJSON)[filename]
 
index 3d9d5270eecb01da617151fef21a6a93541ba214..c60154f3b6369d642ceb2da495cb5540f9a58ba5 100644 (file)
@@ -168,6 +168,7 @@ type P2PMediaLoaderPluginOptions = {
   loader: P2PMediaLoader
 
   requiresAuth: boolean
+  videoFileToken: () => string
 }
 
 export type P2PMediaLoader = {
index 3ce279671415596c15e2a3e9a42e292ccc8b7c66..aa853a383abbfdbe50c4ea672ad0a785023ffa21 100644 (file)
@@ -1,7 +1,10 @@
 import cors from 'cors'
 import express from 'express'
+import { PassThrough, pipeline } from 'stream'
 import { logger } from '@server/helpers/logger'
+import { StreamReplacer } from '@server/helpers/stream-replacer'
 import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants'
+import { injectQueryToPlaylistUrls } from '@server/lib/hls'
 import { getHLSFileReadStream, getWebTorrentFileReadStream } from '@server/lib/object-storage'
 import {
   asyncMiddleware,
@@ -11,6 +14,7 @@ import {
   optionalAuthenticate
 } from '@server/middlewares'
 import { HttpStatusCode } from '@shared/models'
+import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist'
 
 const objectStorageProxyRouter = express.Router()
 
@@ -67,7 +71,20 @@ async function proxifyHLS (req: express.Request, res: express.Response) {
       rangeHeader: req.header('range')
     })
 
-    return stream.pipe(res)
+    const streamReplacer = filename.endsWith('.m3u8') && doReinjectVideoFileToken(req)
+      ? new StreamReplacer(line => injectQueryToPlaylistUrls(line, buildReinjectVideoFileTokenQuery(req)))
+      : new PassThrough()
+
+    return pipeline(
+      stream,
+      streamReplacer,
+      res,
+      err => {
+        if (!err) return
+
+        handleObjectStorageFailure(res, err)
+      }
+    )
   } catch (err) {
     return handleObjectStorageFailure(res, err)
   }
@@ -75,6 +92,7 @@ async function proxifyHLS (req: express.Request, res: express.Response) {
 
 function handleObjectStorageFailure (res: express.Response, err: Error) {
   if (err.name === 'NoSuchKey') {
+    logger.debug('Could not find key in object storage to proxify private HLS video file.', { err })
     return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
   }
 
diff --git a/server/controllers/shared/m3u8-playlist.ts b/server/controllers/shared/m3u8-playlist.ts
new file mode 100644 (file)
index 0000000..e2a66ef
--- /dev/null
@@ -0,0 +1,14 @@
+import express from 'express'
+
+function doReinjectVideoFileToken (req: express.Request) {
+  return req.query.videoFileToken && req.query.reinjectVideoFileToken
+}
+
+function buildReinjectVideoFileTokenQuery (req: express.Request) {
+  return 'videoFileToken=' + req.query.videoFileToken
+}
+
+export {
+  doReinjectVideoFileToken,
+  buildReinjectVideoFileTokenQuery
+}
index 6ef9154b90dff5b418ca5a137ee4baafdc0ab733..52e48267f04f57d7ec7e3a691a8f073b7ab05f86 100644 (file)
@@ -1,5 +1,8 @@
 import cors from 'cors'
 import express from 'express'
+import { readFile } from 'fs-extra'
+import { join } from 'path'
+import { injectQueryToPlaylistUrls } from '@server/lib/hls'
 import {
   asyncMiddleware,
   ensureCanAccessPrivateVideoHLSFiles,
@@ -7,8 +10,10 @@ import {
   handleStaticError,
   optionalAuthenticate
 } from '@server/middlewares'
+import { HttpStatusCode } from '@shared/models'
 import { CONFIG } from '../initializers/config'
 import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants'
+import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist'
 
 const staticRouter = express.Router()
 
@@ -49,6 +54,12 @@ const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AU
   ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ]
   : []
 
+staticRouter.use(
+  STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistName.m3u8',
+  ...privateHLSStaticMiddlewares,
+  asyncMiddleware(servePrivateM3U8)
+)
+
 staticRouter.use(
   STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS,
   ...privateHLSStaticMiddlewares,
@@ -74,3 +85,31 @@ staticRouter.use(
 export {
   staticRouter
 }
+
+// ---------------------------------------------------------------------------
+
+async function servePrivateM3U8 (req: express.Request, res: express.Response) {
+  const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistName + '.m3u8')
+
+  let playlistContent: string
+
+  try {
+    playlistContent = await readFile(path, 'utf-8')
+  } catch (err) {
+    if (err.message.includes('ENOENT')) {
+      return res.fail({
+        status: HttpStatusCode.NOT_FOUND_404,
+        message: 'File not found'
+      })
+    }
+
+    throw err
+  }
+
+  // Inject token in playlist so players that cannot alter the HTTP request can still watch the video
+  const transformedContent = doReinjectVideoFileToken(req)
+    ? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req))
+    : playlistContent
+
+  return res.set('content-type', 'application/vnd.apple.mpegurl').send(transformedContent).end()
+}
diff --git a/server/helpers/stream-replacer.ts b/server/helpers/stream-replacer.ts
new file mode 100644 (file)
index 0000000..4babab4
--- /dev/null
@@ -0,0 +1,58 @@
+import { Transform, TransformCallback } from 'stream'
+
+// Thanks: https://stackoverflow.com/a/45126242
+class StreamReplacer extends Transform {
+  private pendingChunk: Buffer
+
+  constructor (private readonly replacer: (line: string) => string) {
+    super()
+  }
+
+  _transform (chunk: Buffer, _encoding: BufferEncoding, done: TransformCallback) {
+    try {
+      this.pendingChunk = this.pendingChunk?.length
+        ? Buffer.concat([ this.pendingChunk, chunk ])
+        : chunk
+
+      let index: number
+
+      // As long as we keep finding newlines, keep making slices of the buffer and push them to the
+      // readable side of the transform stream
+      while ((index = this.pendingChunk.indexOf('\n')) !== -1) {
+        // The `end` parameter is non-inclusive, so increase it to include the newline we found
+        const line = this.pendingChunk.slice(0, ++index)
+
+        // `start` is inclusive, but we are already one char ahead of the newline -> all good
+        this.pendingChunk = this.pendingChunk.slice(index)
+
+        // We have a single line here! Prepend the string we want
+        this.push(this.doReplace(line))
+      }
+
+      return done()
+    } catch (err) {
+      return done(err)
+    }
+  }
+
+  _flush (done: TransformCallback) {
+    // If we have any remaining data in the cache, send it out
+    if (!this.pendingChunk?.length) return done()
+
+    try {
+      return done(null, this.doReplace(this.pendingChunk))
+    } catch (err) {
+      return done(err)
+    }
+  }
+
+  private doReplace (buffer: Buffer) {
+    const line = this.replacer(buffer.toString('utf8'))
+
+    return Buffer.from(line, 'utf8')
+  }
+}
+
+export {
+  StreamReplacer
+}
index a41f1ae4858461e17ed22047b3e44d11e0bd8a56..053b5d3262f50f59fc9307e158690a87e97cb4af 100644 (file)
@@ -234,13 +234,20 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
 
 // ---------------------------------------------------------------------------
 
+function injectQueryToPlaylistUrls (content: string, queryString: string) {
+  return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
+}
+
+// ---------------------------------------------------------------------------
+
 export {
   updateMasterHLSPlaylist,
   updateSha256VODSegments,
   buildSha256Segment,
   downloadPlaylistSegments,
   updateStreamingPlaylistsInfohashesIfNeeded,
-  updatePlaylistAfterFileChange
+  updatePlaylistAfterFileChange,
+  injectQueryToPlaylistUrls
 }
 
 // ---------------------------------------------------------------------------
index 13fde6dd13e2904db0ef728a3f15b97b56e67e4c..d3d307787a190d8f41efff70572e8e23e315d518 100644 (file)
@@ -2,7 +2,7 @@ import express from 'express'
 import { query } from 'express-validator'
 import LRUCache from 'lru-cache'
 import { basename, dirname } from 'path'
-import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc'
+import { exists, isUUIDValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
 import { logger } from '@server/helpers/logger'
 import { LRU_CACHE } from '@server/initializers/constants'
 import { VideoModel } from '@server/models/video/video'
@@ -60,7 +60,14 @@ const ensureCanAccessVideoPrivateWebTorrentFiles = [
 ]
 
 const ensureCanAccessPrivateVideoHLSFiles = [
-  query('videoFileToken').optional().custom(exists),
+  query('videoFileToken')
+    .optional()
+    .custom(exists),
+
+  query('reinjectVideoFileToken')
+    .optional()
+    .customSanitizer(toBooleanOrNull)
+    .isBoolean().withMessage('Should be a valid reinjectVideoFileToken boolean'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
index 50327b6aebf4eb575b0721131c8c56dd2917e653..64bd9ca70ec4f92a468e5a85f12542702c657aeb 100644 (file)
@@ -44,7 +44,7 @@ const usersListValidator = [
   query('blocked')
     .optional()
     .customSanitizer(toBooleanOrNull)
-    .isBoolean().withMessage('Should be a valid blocked boolena'),
+    .isBoolean().withMessage('Should be a valid blocked boolean'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
index 62edd10ba056eea3b5afd6cf4b96bba349f597a7..71ad35a4346edbef1446c4b261a534f6840480cb 100644 (file)
@@ -2,7 +2,7 @@
 
 import { expect } from 'chai'
 import { basename } from 'path'
-import { expectStartWith } from '@server/tests/shared'
+import { checkVideoFileTokenReinjection, expectStartWith } from '@server/tests/shared'
 import { areScalewayObjectStorageTestsDisabled, getAllFiles, getHLS } from '@shared/core-utils'
 import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
 import {
@@ -191,6 +191,20 @@ describe('Object storage for video static file privacy', function () {
       }
     })
 
+    it('Should reinject video file token', async function () {
+      this.timeout(120000)
+
+      const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID })
+
+      await checkVideoFileTokenReinjection({
+        server,
+        videoUUID: privateVideoUUID,
+        videoFileToken,
+        resolutions: [ 240, 720 ],
+        isLive: false
+      })
+    })
+
     it('Should update public video to private', async function () {
       this.timeout(60000)
 
@@ -315,6 +329,26 @@ describe('Object storage for video static file privacy', function () {
       await checkLiveFiles(permanentLive, permanentLiveId)
     })
 
+    it('Should reinject video file token in permanent live', async function () {
+      this.timeout(240000)
+
+      const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey })
+      await server.live.waitUntilPublished({ videoId: permanentLiveId })
+
+      const video = await server.videos.getWithToken({ id: permanentLiveId })
+      const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
+
+      await checkVideoFileTokenReinjection({
+        server,
+        videoUUID: permanentLiveId,
+        videoFileToken,
+        resolutions: [ 720 ],
+        isLive: true
+      })
+
+      await stopFfmpeg(ffmpegCommand)
+    })
+
     it('Should have created a replay of the normal live with a private static path', async function () {
       this.timeout(240000)
 
index eaaed5aadc6e644339905da29bab2762b75d8caa..ef0774b418576dbaa569a07efc84be202cbe1701 100644 (file)
@@ -2,7 +2,7 @@
 
 import { expect } from 'chai'
 import { decode } from 'magnet-uri'
-import { expectStartWith } from '@server/tests/shared'
+import { checkVideoFileTokenReinjection, expectStartWith } from '@server/tests/shared'
 import { getAllFiles, wait } from '@shared/core-utils'
 import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models'
 import {
@@ -248,6 +248,35 @@ describe('Test video static file privacy', function () {
       await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
     })
 
+    it('Should reinject video file token', async function () {
+      this.timeout(120000)
+
+      const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
+
+      const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid })
+      await waitJobs([ server ])
+
+      const video = await server.videos.getWithToken({ id: uuid })
+      const hls = video.streamingPlaylists[0]
+
+      {
+        const query = { videoFileToken }
+        const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
+
+        expect(text).to.not.include(videoFileToken)
+      }
+
+      {
+        await checkVideoFileTokenReinjection({
+          server,
+          videoUUID: uuid,
+          videoFileToken,
+          resolutions: [ 240, 720 ],
+          isLive: false
+        })
+      }
+    })
+
     it('Should be able to access a private video of another user with an admin OAuth token or file token', async function () {
       this.timeout(120000)
 
@@ -360,6 +389,36 @@ describe('Test video static file privacy', function () {
       await checkLiveFiles(permanentLive, permanentLiveId)
     })
 
+    it('Should reinject video file token on permanent live', async function () {
+      this.timeout(240000)
+
+      const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey })
+      await server.live.waitUntilPublished({ videoId: permanentLiveId })
+
+      const video = await server.videos.getWithToken({ id: permanentLiveId })
+      const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
+      const hls = video.streamingPlaylists[0]
+
+      {
+        const query = { videoFileToken }
+        const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
+
+        expect(text).to.not.include(videoFileToken)
+      }
+
+      {
+        await checkVideoFileTokenReinjection({
+          server,
+          videoUUID: permanentLiveId,
+          videoFileToken,
+          resolutions: [ 720 ],
+          isLive: true
+        })
+      }
+
+      await stopFfmpeg(ffmpegCommand)
+    })
+
     it('Should have created a replay of the normal live with a private static path', async function () {
       this.timeout(240000)
 
index 55ebc6c3e18569b93436d42c24ebf46a6daa22b7..523d37420f9b33f301e68b813dd94add124df9f0 100644 (file)
@@ -23,6 +23,12 @@ function expectNotStartWith (str: string, start: string) {
   expect(str.startsWith(start), `${str} does not start with ${start}`).to.be.false
 }
 
+function expectEndWith (str: string, end: string) {
+  expect(str.endsWith(end), `${str} does not end with ${end}`).to.be.true
+}
+
+// ---------------------------------------------------------------------------
+
 async function expectLogDoesNotContain (server: PeerTubeServer, str: string) {
   const content = await server.servers.getLogContent()
 
@@ -103,6 +109,7 @@ export {
   testFileExistsOrNot,
   expectStartWith,
   expectNotStartWith,
+  expectEndWith,
   checkBadStartPagination,
   checkBadCountPagination,
   checkBadSortPagination,
index 9f7ade53dbe204e44aa93283c71f8d74443e97ff..963ef8fe6e2c36ae46a991c4402b7f4e5d19c74e 100644 (file)
@@ -6,7 +6,7 @@ export * from './directories'
 export * from './generate'
 export * from './live'
 export * from './notifications'
-export * from './playlists'
+export * from './video-playlists'
 export * from './plugins'
 export * from './requests'
 export * from './streaming-playlists'
index 824c3dcefd40095777b5f8f9ab751f153ad94aa0..5c62af812ead06679fa2dedd746cb6023a610f0f 100644 (file)
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { expect } from 'chai'
-import { basename } from 'path'
+import { basename, dirname, join } from 'path'
 import { removeFragmentedMP4Ext, uuidRegex } from '@shared/core-utils'
 import { sha256 } from '@shared/extra-utils'
 import { HttpStatusCode, VideoStreamingPlaylist, VideoStreamingPlaylistType } from '@shared/models'
@@ -188,9 +188,55 @@ async function completeCheckHlsPlaylist (options: {
   }
 }
 
+async function checkVideoFileTokenReinjection (options: {
+  server: PeerTubeServer
+  videoUUID: string
+  videoFileToken: string
+  resolutions: number[]
+  isLive: boolean
+}) {
+  const { server, resolutions, videoFileToken, videoUUID, isLive } = options
+
+  const video = await server.videos.getWithToken({ id: videoUUID })
+  const hls = video.streamingPlaylists[0]
+
+  const query = { videoFileToken, reinjectVideoFileToken: 'true' }
+  const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 })
+
+  for (let i = 0; i < resolutions.length; i++) {
+    const resolution = resolutions[i]
+
+    const suffix = isLive
+      ? i
+      : `-${resolution}`
+
+    expect(text).to.contain(`${suffix}.m3u8?videoFileToken=${videoFileToken}`)
+  }
+
+  const resolutionPlaylists = extractResolutionPlaylistUrls(hls.playlistUrl, text)
+  expect(resolutionPlaylists).to.have.lengthOf(resolutions.length)
+
+  for (const url of resolutionPlaylists) {
+    const { text } = await makeRawRequest({ url, query, expectedStatus: HttpStatusCode.OK_200 })
+
+    const extension = isLive
+      ? '.ts'
+      : '.mp4'
+
+    expect(text).to.contain(`${extension}?videoFileToken=${videoFileToken}`)
+  }
+}
+
+function extractResolutionPlaylistUrls (masterPath: string, masterContent: string) {
+  return masterContent.match(/^([^.]+\.m3u8.*)/mg)
+    .map(filename => join(dirname(masterPath), filename))
+}
+
 export {
   checkSegmentHash,
   checkLiveSegmentHash,
   checkResolutionsInMasterPlaylist,
-  completeCheckHlsPlaylist
+  completeCheckHlsPlaylist,
+  extractResolutionPlaylistUrls,
+  checkVideoFileTokenReinjection
 }
index d1c399f7b5f158314e5199e3b281bc1855eb5f19..33fc5ee3a8c25a5cb678a8862d79a536c5ed8426 100644 (file)
@@ -11,6 +11,14 @@ function addQueryParams (url: string, params: { [ id: string ]: string }) {
   return objUrl.toString()
 }
 
+function removeQueryParams (url: string) {
+  const objUrl = new URL(url)
+
+  objUrl.searchParams.forEach((_v, k) => objUrl.searchParams.delete(k))
+
+  return objUrl.toString()
+}
+
 function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
   return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
 }
@@ -114,6 +122,7 @@ function decoratePlaylistLink (options: {
 
 export {
   addQueryParams,
+  removeQueryParams,
 
   buildPlaylistLink,
   buildVideoLink,
index 25e446e729b2c55c3a65c0d5536114286454d6de..26ab2735fd33338ef19733e5c8bf6493506f35c9 100644 (file)
@@ -7,16 +7,24 @@ export class StreamingPlaylistsCommand extends AbstractCommand {
 
   async get (options: OverrideCommandOptions & {
     url: string
+
+    videoFileToken?: string
+    reinjectVideoFileToken?: boolean
+
     withRetry?: boolean // default false
     currentRetry?: number
   }) {
-    const { withRetry, currentRetry = 1 } = options
+    const { videoFileToken, reinjectVideoFileToken, withRetry, currentRetry = 1 } = options
 
     try {
       const result = await unwrapTextOrDecode(this.getRawRequest({
         ...options,
 
         url: options.url,
+        query: {
+          videoFileToken,
+          reinjectVideoFileToken
+        },
         implicitToken: false,
         defaultExpectedStatus: HttpStatusCode.OK_200
       }))
index 2062f2e3a121a4be67545bf2baff5cc89dee737f..c2f9d424e1156240b9a34c8f9d3689ae4a1bc43a 100644 (file)
@@ -408,6 +408,7 @@ paths:
       parameters:
         - $ref: '#/components/parameters/staticFilename'
         - $ref: '#/components/parameters/videoFileToken'
+        - $ref: '#/components/parameters/reinjectVideoFileToken'
       security:
         - OAuth2: []
       responses:
@@ -5711,7 +5712,13 @@ components:
       description: Video file token [generated](#operation/requestVideoToken) by PeerTube so you don't need to provide an OAuth token in the request header.
       schema:
         type: string
-
+    reinjectVideoFileToken:
+      name: reinjectVideoFileToken
+      in: query
+      required: false
+      description: Ask the server to reinject videoFileToken in URLs in m3u8 playlist
+      schema:
+        type: boolean
 
   securitySchemes:
     OAuth2: