diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 9ae6f9f12..b3818c8de 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -20,12 +20,12 @@ import { } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { isXPercentInViewport, scrollToTop } from '@app/helpers' -import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { logger } from '@root-helpers/logger' -import { isP2PEnabled } from '@root-helpers/video' +import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' import { timeToInt } from '@shared/core-utils' import { HTMLServerConfig, @@ -78,6 +78,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private nextVideoUUID = '' private nextVideoTitle = '' + private videoFileToken: string + private currentTime: number private paramsSub: Subscription @@ -110,6 +112,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private pluginService: PluginService, private peertubeSocket: PeerTubeSocket, private screenService: ScreenService, + private videoFileTokenService: VideoFileTokenService, private location: PlatformLocation, @Inject(LOCALE_ID) private localeId: string ) { } @@ -252,12 +255,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy { 'filter:api.video-watch.video.get.result' ) - const videoAndLiveObs: Observable<{ video: VideoDetails, live?: LiveVideo }> = videoObs.pipe( + const videoAndLiveObs: Observable<{ video: VideoDetails, live?: LiveVideo, videoFileToken?: string }> = videoObs.pipe( switchMap(video => { - if (!video.isLive) return of({ video }) + if (!video.isLive) return of({ video, live: undefined }) return this.liveVideoService.getVideoLive(video.uuid) .pipe(map(live => ({ live, video }))) + }), + + switchMap(({ video, live }) => { + if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) + + return this.videoFileTokenService.getVideoFileToken(video.uuid) + .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) }) ) @@ -266,7 +276,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.videoCaptionService.listCaptions(videoId), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ - next: ([ { video, live }, captionsResult, loggedInOrAnonymousUser ]) => { + next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { const queryParams = this.route.snapshot.queryParams const urlOptions = { @@ -283,7 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { peertubeLink: false } - this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions }) + this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, videoFileToken, loggedInOrAnonymousUser, urlOptions }) .catch(err => this.handleGlobalError(err)) }, @@ -356,16 +366,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails live: LiveVideo videoCaptions: VideoCaption[] + videoFileToken: string + urlOptions: URLOptions loggedInOrAnonymousUser: User }) { - const { video, live, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options + const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser } = options this.subscribeToLiveEventsIfNeeded(this.video, video) this.video = video this.videoCaptions = videoCaptions this.liveVideo = live + this.videoFileToken = videoFileToken // Re init attributes this.playerPlaceholderImgSrc = undefined @@ -414,6 +427,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: this.video, videoCaptions: this.videoCaptions, liveVideo: this.liveVideo, + videoFileToken: this.videoFileToken, urlOptions, loggedInOrAnonymousUser, user: this.user @@ -561,11 +575,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails liveVideo: LiveVideo videoCaptions: VideoCaption[] + + videoFileToken: string + urlOptions: CustomizationOptions & { playerMode: PlayerMode } + loggedInOrAnonymousUser: User user?: AuthUser // Keep for plugins }) { - const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser } = params + const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser } = params const getStartTime = () => { const byUrl = urlOptions.startTime !== undefined @@ -623,13 +641,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { theaterButton: true, captions: videoCaptions.length !== 0, - videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE - ? this.videoService.getVideoViewUrl(video.uuid) - : null, - authorizationHeader: this.authService.getRequestHeaderValue(), - - metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', - embedUrl: video.embedUrl, embedTitle: video.name, instanceName: this.serverConfig.instance.name, @@ -639,7 +650,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy { language: this.localeId, - serverUrl: environment.apiUrl, + metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', + + videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE + ? this.videoService.getVideoViewUrl(video.uuid) + : null, + authorizationHeader: () => this.authService.getRequestHeaderValue(), + + serverUrl: environment.originServerUrl, + + videoFileToken: () => videoFileToken, + requiresAuth: videoRequiresAuth(video), videoCaptions: playerCaptions, diff --git a/client/src/app/core/auth/auth-user.model.ts b/client/src/app/core/auth/auth-user.model.ts index cd9665e37..a12325421 100644 --- a/client/src/app/core/auth/auth-user.model.ts +++ b/client/src/app/core/auth/auth-user.model.ts @@ -1,7 +1,7 @@ import { Observable, of } from 'rxjs' import { map } from 'rxjs/operators' import { User } from '@app/core/users/user.model' -import { UserTokens } from '@root-helpers/users' +import { OAuthUserTokens } from '@root-helpers/users' import { hasUserRight } from '@shared/core-utils/users' import { MyUser as ServerMyUserModel, @@ -13,33 +13,33 @@ import { } from '@shared/models' export class AuthUser extends User implements ServerMyUserModel { - tokens: UserTokens + oauthTokens: OAuthUserTokens specialPlaylists: MyUserSpecialPlaylist[] canSeeVideosLink = true - constructor (userHash: Partial, hashTokens: Partial) { + constructor (userHash: Partial, hashTokens: Partial) { super(userHash) - this.tokens = new UserTokens(hashTokens) + this.oauthTokens = new OAuthUserTokens(hashTokens) this.specialPlaylists = userHash.specialPlaylists } getAccessToken () { - return this.tokens.accessToken + return this.oauthTokens.accessToken } getRefreshToken () { - return this.tokens.refreshToken + return this.oauthTokens.refreshToken } getTokenType () { - return this.tokens.tokenType + return this.oauthTokens.tokenType } refreshTokens (accessToken: string, refreshToken: string) { - this.tokens.accessToken = accessToken - this.tokens.refreshToken = refreshToken + this.oauthTokens.accessToken = accessToken + this.oauthTokens.refreshToken = refreshToken } hasRight (right: UserRight) { diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index 7f4fae4aa..4de28e51e 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -5,7 +5,7 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular import { Injectable } from '@angular/core' import { Router } from '@angular/router' import { Notifier } from '@app/core/notification/notifier.service' -import { logger, objectToUrlEncoded, peertubeLocalStorage, UserTokens } from '@root-helpers/index' +import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' import { environment } from '../../../environments/environment' import { RestExtractor } from '../rest/rest-extractor.service' @@ -74,7 +74,7 @@ export class AuthService { ] } - buildAuthUser (userInfo: Partial, tokens: UserTokens) { + buildAuthUser (userInfo: Partial, tokens: OAuthUserTokens) { this.user = new AuthUser(userInfo, tokens) } diff --git a/client/src/app/core/users/user-local-storage.service.ts b/client/src/app/core/users/user-local-storage.service.ts index fff649eef..f1588bdd2 100644 --- a/client/src/app/core/users/user-local-storage.service.ts +++ b/client/src/app/core/users/user-local-storage.service.ts @@ -4,7 +4,7 @@ import { Injectable } from '@angular/core' import { AuthService, AuthStatus } from '@app/core/auth' import { getBoolOrDefault } from '@root-helpers/local-storage-utils' import { logger } from '@root-helpers/logger' -import { UserLocalStorageKeys, UserTokens } from '@root-helpers/users' +import { UserLocalStorageKeys, OAuthUserTokens } from '@root-helpers/users' import { UserRole, UserUpdateMe } from '@shared/models' import { NSFWPolicyType } from '@shared/models/videos' import { ServerService } from '../server' @@ -24,7 +24,7 @@ export class UserLocalStorageService { this.setLoggedInUser(user) this.setUserInfo(user) - this.setTokens(user.tokens) + this.setTokens(user.oauthTokens) } }) @@ -43,7 +43,7 @@ export class UserLocalStorageService { next: () => { const user = this.authService.getUser() - this.setTokens(user.tokens) + this.setTokens(user.oauthTokens) } }) } @@ -174,14 +174,14 @@ export class UserLocalStorageService { // --------------------------------------------------------------------------- getTokens () { - return UserTokens.getUserTokens(this.localStorageService) + return OAuthUserTokens.getUserTokens(this.localStorageService) } - setTokens (tokens: UserTokens) { - UserTokens.saveToLocalStorage(this.localStorageService, tokens) + setTokens (tokens: OAuthUserTokens) { + OAuthUserTokens.saveToLocalStorage(this.localStorageService, tokens) } flushTokens () { - UserTokens.flushLocalStorage(this.localStorageService) + OAuthUserTokens.flushLocalStorage(this.localStorageService) } } diff --git a/client/src/app/helpers/utils/url.ts b/client/src/app/helpers/utils/url.ts index 08c27e3c1..9e7dc3e6f 100644 --- a/client/src/app/helpers/utils/url.ts +++ b/client/src/app/helpers/utils/url.ts @@ -54,8 +54,9 @@ function objectToFormData (obj: any, form?: FormData, namespace?: string) { } export { - objectToFormData, getAbsoluteAPIUrl, getAPIHost, - getAbsoluteEmbedUrl + getAbsoluteEmbedUrl, + + objectToFormData } diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 04b223cc5..c1523bc50 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -44,7 +44,15 @@ import { import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins' import { ActorRedirectGuard } from './router' import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' -import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoResolver, VideoService } from './video' +import { + EmbedComponent, + RedundancyService, + VideoFileTokenService, + VideoImportService, + VideoOwnershipService, + VideoResolver, + VideoService +} from './video' import { VideoCaptionService } from './video-caption' import { VideoChannelService } from './video-channel' @@ -185,6 +193,7 @@ import { VideoChannelService } from './video-channel' VideoImportService, VideoOwnershipService, VideoService, + VideoFileTokenService, VideoResolver, VideoCaptionService, diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index 361601456..a2e47883e 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts @@ -2,6 +2,7 @@ export * from './embed.component' export * from './redundancy.service' export * from './video-details.model' export * from './video-edit.model' +export * from './video-file-token.service' export * from './video-import.service' export * from './video-ownership.service' export * from './video.model' diff --git a/client/src/app/shared/shared-main/video/video-file-token.service.ts b/client/src/app/shared/shared-main/video/video-file-token.service.ts new file mode 100644 index 000000000..791607249 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-file-token.service.ts @@ -0,0 +1,33 @@ +import { catchError, map, of, tap } from 'rxjs' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { VideoToken } from '@shared/models' +import { VideoService } from './video.service' + +@Injectable() +export class VideoFileTokenService { + + private readonly store = new Map() + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) {} + + getVideoFileToken (videoUUID: string) { + const existing = this.store.get(videoUUID) + if (existing) return of(existing) + + return this.createVideoFileToken(videoUUID) + .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) + } + + private createVideoFileToken (videoUUID: string) { + return this.authHttp.post(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) + .pipe( + map(({ files }) => files), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.html b/client/src/app/shared/shared-video-miniature/video-download.component.html index 1c7458b4b..1f622933d 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.html +++ b/client/src/app/shared/shared-video-miniature/video-download.component.html @@ -48,10 +48,7 @@ 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 47482caaa..667cb107f 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 @@ -2,11 +2,12 @@ import { mapValues, pick } from 'lodash-es' import { firstValueFrom } from 'rxjs' import { tap } from 'rxjs/operators' import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' -import { AuthService, HooksService, Notifier } from '@app/core' +import { HooksService } from '@app/core' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { logger } from '@root-helpers/logger' +import { videoRequiresAuth } from '@root-helpers/video' import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' -import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main' +import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' type DownloadType = 'video' | 'subtitles' type FileMetadata = { [key: string]: { label: string, value: string }} @@ -32,6 +33,8 @@ export class VideoDownloadComponent { type: DownloadType = 'video' + videoFileToken: string + private activeModal: NgbModalRef private bytesPipe: BytesPipe @@ -42,10 +45,9 @@ export class VideoDownloadComponent { constructor ( @Inject(LOCALE_ID) private localeId: string, - private notifier: Notifier, private modalService: NgbModal, private videoService: VideoService, - private auth: AuthService, + private videoFileTokenService: VideoFileTokenService, private hooks: HooksService ) { this.bytesPipe = new BytesPipe() @@ -71,6 +73,8 @@ export class VideoDownloadComponent { } show (video: VideoDetails, videoCaptions?: VideoCaption[]) { + this.videoFileToken = undefined + this.video = video this.videoCaptions = videoCaptions @@ -84,6 +88,11 @@ export class VideoDownloadComponent { this.subtitleLanguageId = this.videoCaptions[0].language.id } + if (videoRequiresAuth(this.video)) { + this.videoFileTokenService.getVideoFileToken(this.video.uuid) + .subscribe(({ token }) => this.videoFileToken = token) + } + this.activeModal.shown.subscribe(() => { this.hooks.runAction('action:modal.video-download.shown', 'common') }) @@ -155,7 +164,7 @@ export class VideoDownloadComponent { if (!file) return '' const suffix = this.isConfidentialVideo() - ? '?access_token=' + this.auth.getAccessToken() + ? '?videoFileToken=' + this.videoFileToken : '' switch (this.downloadType) { diff --git a/client/src/assets/player/shared/common/utils.ts b/client/src/assets/player/shared/common/utils.ts index a010d9184..609240626 100644 --- a/client/src/assets/player/shared/common/utils.ts +++ b/client/src/assets/player/shared/common/utils.ts @@ -52,6 +52,10 @@ function getRtcConfig () { } } +function isSameOrigin (current: string, target: string) { + return new URL(current).origin === new URL(target).origin +} + // --------------------------------------------------------------------------- export { @@ -60,5 +64,7 @@ export { videoFileMaxByResolution, videoFileMinByResolution, - bytes + bytes, + + isSameOrigin } diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/manager-options/hls-options-builder.ts index 361c76f4b..933c0d595 100644 --- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts +++ b/client/src/assets/player/shared/manager-options/hls-options-builder.ts @@ -5,7 +5,7 @@ import { LiveVideoLatencyMode } from '@shared/models' import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' import { PeertubePlayerManagerOptions } from '../../types/manager-options' -import { getRtcConfig } from '../common' +import { getRtcConfig, isSameOrigin } from '../common' import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' @@ -84,7 +84,21 @@ export class HLSOptionsBuilder { simultaneousHttpDownloads: 1, httpFailedSegmentTimeout: 1000, - segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive), + xhrSetup: (xhr, url) => { + if (!this.options.common.requiresAuth) return + if (!isSameOrigin(this.options.common.serverUrl, url)) return + + xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) + }, + + segmentValidator: segmentValidatorFactory({ + segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, + isLive: this.options.common.isLive, + authorizationHeader: this.options.common.authorizationHeader, + requiresAuth: this.options.common.requiresAuth, + serverUrl: this.options.common.serverUrl + }), + segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), useP2P: this.options.common.p2pEnabled, diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts index 257cf1e05..b5bdcd4e6 100644 --- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts +++ b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts @@ -1,4 +1,5 @@ -import { PeertubePlayerManagerOptions } from '../../types' +import { addQueryParams } from '../../../../../../shared/core-utils' +import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types' export class WebTorrentOptionsBuilder { @@ -16,13 +17,23 @@ export class WebTorrentOptionsBuilder { const autoplay = this.autoPlayValue === 'play' - const webtorrent = { + const webtorrent: WebtorrentPluginOptions = { autoplay, playerRefusedP2P: commonOptions.p2pEnabled === false, videoDuration: commonOptions.videoDuration, playerElement: commonOptions.playerElement, + videoFileToken: commonOptions.videoFileToken, + + requiresAuth: commonOptions.requiresAuth, + + buildWebSeedUrls: file => { + if (!commonOptions.requiresAuth) return [] + + return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] + }, + videoFiles: webtorrentOptions.videoFiles.length !== 0 ? webtorrentOptions.videoFiles // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index 18cb6750f..a7ee91950 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts @@ -2,13 +2,22 @@ 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 { isSameOrigin } from '../common' type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } const maxRetries = 3 -function segmentValidatorFactory (segmentsSha256Url: string, isLive: boolean) { - let segmentsJSON = fetchSha256Segments(segmentsSha256Url) +function segmentValidatorFactory (options: { + serverUrl: string + segmentsSha256Url: string + isLive: boolean + authorizationHeader: () => string + requiresAuth: boolean +}) { + const { serverUrl, segmentsSha256Url, isLive, authorizationHeader, requiresAuth } = options + + let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) const regex = /bytes=(\d+)-(\d+)/ return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { @@ -28,7 +37,7 @@ function segmentValidatorFactory (segmentsSha256Url: string, isLive: boolean) { await wait(1000) - segmentsJSON = fetchSha256Segments(segmentsSha256Url) + segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) await segmentValidator(segment, _method, _peerId, retry + 1) return @@ -68,8 +77,19 @@ export { // --------------------------------------------------------------------------- -function fetchSha256Segments (url: string) { - return fetch(url) +function fetchSha256Segments (options: { + serverUrl: string + segmentsSha256Url: string + authorizationHeader: () => string + requiresAuth: boolean +}) { + const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options + + const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url) + ? { Authorization: authorizationHeader() } + : {} + + return fetch(segmentsSha256Url, { headers }) .then(res => res.json() as Promise) .catch(err => { logger.error('Cannot get sha256 segments', err) diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts index a5d712d70..4bd038bb1 100644 --- a/client/src/assets/player/shared/peertube/peertube-plugin.ts +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts @@ -22,7 +22,7 @@ const Plugin = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { private readonly videoViewUrl: string - private readonly authorizationHeader: string + private readonly authorizationHeader: () => string private readonly videoUUID: string private readonly startTime: number @@ -228,7 +228,7 @@ class PeerTubePlugin extends Plugin { 'Content-type': 'application/json; charset=UTF-8' }) - if (this.authorizationHeader) headers.set('Authorization', this.authorizationHeader) + if (this.authorizationHeader) headers.set('Authorization', this.authorizationHeader()) return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) } diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts index fa3f48a9a..658b7c867 100644 --- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts @@ -2,7 +2,7 @@ import videojs from 'video.js' import * as WebTorrent from 'webtorrent' import { logger } from '@root-helpers/logger' import { isIOS } from '@root-helpers/web-browser' -import { timeToInt } from '@shared/core-utils' +import { addQueryParams, timeToInt } from '@shared/core-utils' import { VideoFile } from '@shared/models' import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' @@ -38,6 +38,8 @@ class WebTorrentPlugin extends Plugin { BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth } + private readonly buildWebSeedUrls: (file: VideoFile) => string[] + private readonly webtorrent = new WebTorrent({ tracker: { rtcConfig: getRtcConfig() @@ -57,6 +59,9 @@ class WebTorrentPlugin extends Plugin { private isAutoResolutionObservation = false private playerRefusedP2P = false + private requiresAuth: boolean + private videoFileToken: () => string + private torrentInfoInterval: any private autoQualityInterval: any private addTorrentDelay: any @@ -81,6 +86,11 @@ class WebTorrentPlugin extends Plugin { this.savePlayerSrcFunction = this.player.src this.playerElement = options.playerElement + this.requiresAuth = options.requiresAuth + this.videoFileToken = options.videoFileToken + + this.buildWebSeedUrls = options.buildWebSeedUrls + this.player.ready(() => { const playerOptions = this.player.options_ @@ -268,7 +278,8 @@ class WebTorrentPlugin extends Plugin { return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { max: 100 }) - } + }, + urlList: this.buildWebSeedUrls(this.currentVideoFile) } this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { @@ -533,7 +544,12 @@ class WebTorrentPlugin extends Plugin { // Enable error display now this is our last fallback this.player.one('error', () => this.player.peertube().displayFatalError()) - const httpUrl = this.currentVideoFile.fileUrl + let httpUrl = this.currentVideoFile.fileUrl + + if (this.requiresAuth && this.videoFileToken) { + httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) + } + this.player.src = this.savePlayerSrcFunction this.player.src(httpUrl) diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts index b4d9374c3..9da8fedf8 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/manager-options.ts @@ -57,7 +57,7 @@ export interface CommonOptions extends CustomizationOptions { captions: boolean videoViewUrl: string - authorizationHeader?: string + authorizationHeader?: () => string metricsUrl: string @@ -77,6 +77,8 @@ export interface CommonOptions extends CustomizationOptions { videoShortUUID: string serverUrl: string + requiresAuth: boolean + videoFileToken: () => string errorNotifier: (message: string) => void } diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 6df94992c..037c4b74b 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -95,7 +95,7 @@ type PeerTubePluginOptions = { videoDuration: number videoViewUrl: string - authorizationHeader?: string + authorizationHeader?: () => string subtitle?: string @@ -151,6 +151,11 @@ type WebtorrentPluginOptions = { startTime: number | string playerRefusedP2P: boolean + + requiresAuth: boolean + videoFileToken: () => string + + buildWebSeedUrls: (file: VideoFile) => string[] } type P2PMediaLoaderPluginOptions = { diff --git a/client/src/root-helpers/logger.ts b/client/src/root-helpers/logger.ts index 0d486c433..d1fdf73aa 100644 --- a/client/src/root-helpers/logger.ts +++ b/client/src/root-helpers/logger.ts @@ -1,6 +1,6 @@ import { ClientLogCreate } from '@shared/models/server' import { peertubeLocalStorage } from './peertube-web-storage' -import { UserTokens } from './users' +import { OAuthUserTokens } from './users' export type LoggerHook = (message: LoggerMessage, meta?: LoggerMeta) => void export type LoggerLevel = 'info' | 'warn' | 'error' @@ -56,7 +56,7 @@ class Logger { }) try { - const tokens = UserTokens.getUserTokens(peertubeLocalStorage) + const tokens = OAuthUserTokens.getUserTokens(peertubeLocalStorage) if (tokens) headers.set('Authorization', `${tokens.tokenType} ${tokens.accessToken}`) } catch (err) { diff --git a/client/src/root-helpers/users/index.ts b/client/src/root-helpers/users/index.ts index 2b11d0b7e..c03e67325 100644 --- a/client/src/root-helpers/users/index.ts +++ b/client/src/root-helpers/users/index.ts @@ -1,2 +1,2 @@ export * from './user-local-storage-keys' -export * from './user-tokens' +export * from './oauth-user-tokens' diff --git a/client/src/root-helpers/users/user-tokens.ts b/client/src/root-helpers/users/oauth-user-tokens.ts similarity index 91% rename from client/src/root-helpers/users/user-tokens.ts rename to client/src/root-helpers/users/oauth-user-tokens.ts index a6d614cb7..a24e76b91 100644 --- a/client/src/root-helpers/users/user-tokens.ts +++ b/client/src/root-helpers/users/oauth-user-tokens.ts @@ -1,11 +1,11 @@ import { UserTokenLocalStorageKeys } from './user-local-storage-keys' -export class UserTokens { +export class OAuthUserTokens { accessToken: string refreshToken: string tokenType: string - constructor (hash?: Partial) { + constructor (hash?: Partial) { if (hash) { this.accessToken = hash.accessToken this.refreshToken = hash.refreshToken @@ -25,14 +25,14 @@ export class UserTokens { if (!accessTokenLocalStorage || !refreshTokenLocalStorage || !tokenTypeLocalStorage) return null - return new UserTokens({ + return new OAuthUserTokens({ accessToken: accessTokenLocalStorage, refreshToken: refreshTokenLocalStorage, tokenType: tokenTypeLocalStorage }) } - static saveToLocalStorage (localStorage: Pick, tokens: UserTokens) { + static saveToLocalStorage (localStorage: Pick, tokens: OAuthUserTokens) { localStorage.setItem(UserTokenLocalStorageKeys.ACCESS_TOKEN, tokens.accessToken) localStorage.setItem(UserTokenLocalStorageKeys.REFRESH_TOKEN, tokens.refreshToken) localStorage.setItem(UserTokenLocalStorageKeys.TOKEN_TYPE, tokens.tokenType) diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts index ba84e49ea..107ba1eba 100644 --- a/client/src/root-helpers/video.ts +++ b/client/src/root-helpers/video.ts @@ -1,4 +1,4 @@ -import { HTMLServerConfig, Video } from '@shared/models' +import { HTMLServerConfig, Video, VideoPrivacy } from '@shared/models' function buildVideoOrPlaylistEmbed (options: { embedUrl: string @@ -26,9 +26,14 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b return userP2PEnabled } +function videoRequiresAuth (video: Video) { + return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) +} + export { buildVideoOrPlaylistEmbed, - isP2PEnabled + isP2PEnabled, + videoRequiresAuth } // --------------------------------------------------------------------------- diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 451e54840..c6160151a 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -6,7 +6,7 @@ import { peertubeTranslate } from '../../../../shared/core-utils/i18n' import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models' import { PeertubePlayerManager } from '../../assets/player' import { TranslationsManager } from '../../assets/player/translations-manager' -import { getParamString, logger } from '../../root-helpers' +import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' import { PeerTubeEmbedApi } from './embed-api' import { AuthHTTP, LiveManager, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared' import { PlayerHTML } from './shared/player-html' @@ -167,22 +167,25 @@ export class PeerTubeEmbed { private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise) { const alreadyHadPlayer = this.resetPlayerElement() - const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json() - .then((videoInfo: VideoDetails) => { + const videoInfoPromise = videoResponse.json() + .then(async (videoInfo: VideoDetails) => { this.playerManagerOptions.loadParams(this.config, videoInfo) if (!alreadyHadPlayer && !this.playerManagerOptions.hasAutoplay()) { this.playerHTML.buildPlaceholder(videoInfo) } + const live = videoInfo.isLive + ? await this.videoFetcher.loadLive(videoInfo) + : undefined - if (!videoInfo.isLive) { - return { video: videoInfo } - } + const videoFileToken = videoRequiresAuth(videoInfo) + ? await this.videoFetcher.loadVideoToken(videoInfo) + : undefined - return this.videoFetcher.loadVideoWithLive(videoInfo) + return { live, video: videoInfo, videoFileToken } }) - const [ { video, live }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ + const [ { video, live, videoFileToken }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ videoInfoPromise, this.translationsPromise, captionsPromise, @@ -200,6 +203,9 @@ export class PeerTubeEmbed { translations, serverConfig: this.config, + authorizationHeader: () => this.http.getHeaderTokenValue(), + videoFileToken: () => videoFileToken, + onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer(uuid), playlistTracker: this.playlistTracker, diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts index 0356ab8a6..43af5dff4 100644 --- a/client/src/standalone/videos/shared/auth-http.ts +++ b/client/src/standalone/videos/shared/auth-http.ts @@ -1,5 +1,5 @@ import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models' -import { objectToUrlEncoded, UserTokens } from '../../../root-helpers' +import { OAuthUserTokens, objectToUrlEncoded } from '../../../root-helpers' import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage' export class AuthHTTP { @@ -8,30 +8,30 @@ export class AuthHTTP { CLIENT_SECRET: 'client_secret' } - private userTokens: UserTokens + private userOAuthTokens: OAuthUserTokens private headers = new Headers() constructor () { - this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage) + this.userOAuthTokens = OAuthUserTokens.getUserTokens(peertubeLocalStorage) - if (this.userTokens) this.setHeadersFromTokens() + if (this.userOAuthTokens) this.setHeadersFromTokens() } - fetch (url: string, { optionalAuth }: { optionalAuth: boolean }) { + fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { const refreshFetchOptions = optionalAuth ? { headers: this.headers } : {} - return this.refreshFetch(url.toString(), refreshFetchOptions) + return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) } getHeaderTokenValue () { - return `${this.userTokens.tokenType} ${this.userTokens.accessToken}` + return `${this.userOAuthTokens.tokenType} ${this.userOAuthTokens.accessToken}` } isLoggedIn () { - return !!this.userTokens + return !!this.userOAuthTokens } private refreshFetch (url: string, options?: RequestInit) { @@ -47,7 +47,7 @@ export class AuthHTTP { headers.set('Content-Type', 'application/x-www-form-urlencoded') const data = { - refresh_token: this.userTokens.refreshToken, + refresh_token: this.userOAuthTokens.refreshToken, client_id: clientId, client_secret: clientSecret, response_type: 'code', @@ -64,15 +64,15 @@ export class AuthHTTP { return res.json() }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => { if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) { - UserTokens.flushLocalStorage(peertubeLocalStorage) + OAuthUserTokens.flushLocalStorage(peertubeLocalStorage) this.removeTokensFromHeaders() return resolve() } - this.userTokens.accessToken = obj.access_token - this.userTokens.refreshToken = obj.refresh_token - UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens) + this.userOAuthTokens.accessToken = obj.access_token + this.userOAuthTokens.refreshToken = obj.refresh_token + OAuthUserTokens.saveToLocalStorage(peertubeLocalStorage, this.userOAuthTokens) this.setHeadersFromTokens() @@ -84,7 +84,7 @@ export class AuthHTTP { return refreshingTokenPromise .catch(() => { - UserTokens.flushLocalStorage(peertubeLocalStorage) + OAuthUserTokens.flushLocalStorage(peertubeLocalStorage) this.removeTokensFromHeaders() }).then(() => fetch(url, { diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts index eed821994..87a84975b 100644 --- a/client/src/standalone/videos/shared/player-manager-options.ts +++ b/client/src/standalone/videos/shared/player-manager-options.ts @@ -17,7 +17,8 @@ import { isP2PEnabled, logger, peertubeLocalStorage, - UserLocalStorageKeys + UserLocalStorageKeys, + videoRequiresAuth } from '../../../root-helpers' import { PeerTubePlugin } from './peertube-plugin' import { PlayerHTML } from './player-html' @@ -154,6 +155,9 @@ export class PlayerManagerOptions { captionsResponse: Response live?: LiveVideo + authorizationHeader: () => string + videoFileToken: () => string + serverConfig: HTMLServerConfig alreadyHadPlayer: boolean @@ -169,9 +173,11 @@ export class PlayerManagerOptions { video, captionsResponse, alreadyHadPlayer, + videoFileToken, translations, playlistTracker, live, + authorizationHeader, serverConfig } = options @@ -227,6 +233,10 @@ export class PlayerManagerOptions { embedUrl: window.location.origin + video.embedPath, embedTitle: video.name, + requiresAuth: videoRequiresAuth(video), + authorizationHeader, + videoFileToken, + errorNotifier: () => { // Empty, we don't have a notifier in the embed }, diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index b42d622f9..cf6d12831 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts @@ -1,4 +1,4 @@ -import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models' +import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' import { logger } from '../../../root-helpers' import { AuthHTTP } from './auth-http' @@ -36,10 +36,15 @@ export class VideoFetcher { return { captionsPromise, videoResponse } } - loadVideoWithLive (video: VideoDetails) { + loadLive (video: VideoDetails) { return this.http.fetch(this.getLiveUrl(video.uuid), { optionalAuth: true }) - .then(res => res.json()) - .then((live: LiveVideo) => ({ video, live })) + .then(res => res.json() as Promise) + } + + loadVideoToken (video: VideoDetails) { + return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) + .then(res => res.json() as Promise) + .then(token => token.files.token) } getVideoViewsUrl (videoUUID: string) { @@ -61,4 +66,8 @@ export class VideoFetcher { private getLiveUrl (videoId: string) { return window.location.origin + '/api/v1/videos/live/' + videoId } + + private getVideoTokenUrl (id: string) { + return this.getVideoUrl(id) + '/token' + } } diff --git a/package.json b/package.json index 6dcf26253..23cd9e112 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@peertube/http-signature": "^1.7.0", "@uploadx/core": "^6.0.0", "async-lru": "^1.1.1", + "async-mutex": "^0.4.0", "bcrypt": "5.0.1", "bencode": "^2.0.2", "bittorrent-tracker": "^9.0.0", @@ -177,7 +178,6 @@ }, "devDependencies": { "@peertube/maildev": "^1.2.0", - "@types/async-lock": "^1.1.0", "@types/bcrypt": "^5.0.0", "@types/bencode": "^2.0.0", "@types/bluebird": "^3.5.33", diff --git a/scripts/migrations/peertube-2.1.ts b/scripts/migrations/peertube-2.1.ts deleted file mode 100644 index 2e316d996..000000000 --- a/scripts/migrations/peertube-2.1.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { pathExists, stat, writeFile } from 'fs-extra' -import parseTorrent from 'parse-torrent' -import { join } from 'path' -import * as Sequelize from 'sequelize' -import { logger } from '@server/helpers/logger' -import { createTorrentPromise } from '@server/helpers/webtorrent' -import { CONFIG } from '@server/initializers/config' -import { HLS_STREAMING_PLAYLIST_DIRECTORY, STATIC_PATHS, WEBSERVER } from '@server/initializers/constants' -import { initDatabaseModels, sequelizeTypescript } from '../../server/initializers/database' - -run() - .then(() => process.exit(0)) - .catch(err => { - console.error(err) - process.exit(-1) - }) - -async function run () { - logger.info('Creating torrents and updating database for HSL files.') - - await initDatabaseModels(true) - - const query = 'select "videoFile".id as id, "videoFile".resolution as resolution, "video".uuid as uuid from "videoFile" ' + - 'inner join "videoStreamingPlaylist" ON "videoStreamingPlaylist".id = "videoFile"."videoStreamingPlaylistId" ' + - 'inner join video ON video.id = "videoStreamingPlaylist"."videoId" ' + - 'WHERE video.remote IS FALSE' - const options = { - type: Sequelize.QueryTypes.SELECT - } - const res = await sequelizeTypescript.query(query, options) - - for (const row of res) { - const videoFilename = `${row['uuid']}-${row['resolution']}-fragmented.mp4` - const videoFilePath = join(HLS_STREAMING_PLAYLIST_DIRECTORY, row['uuid'], videoFilename) - - logger.info('Processing %s.', videoFilePath) - - if (!await pathExists(videoFilePath)) { - console.warn('Cannot generate torrent of %s: file does not exist.', videoFilePath) - continue - } - - const createTorrentOptions = { - // Keep the extname, it's used by the client to stream the file inside a web browser - name: `video ${row['uuid']}`, - createdBy: 'PeerTube', - announceList: [ - [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ], - [ WEBSERVER.URL + '/tracker/announce' ] - ], - urlList: [ WEBSERVER.URL + join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, row['uuid'], videoFilename) ] - } - const torrent = await createTorrentPromise(videoFilePath, createTorrentOptions) - - const torrentName = `${row['uuid']}-${row['resolution']}-hls.torrent` - const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentName) - - await writeFile(filePath, torrent) - - const parsedTorrent = parseTorrent(torrent) - const infoHash = parsedTorrent.infoHash - - const stats = await stat(videoFilePath) - const size = stats.size - - const queryUpdate = 'UPDATE "videoFile" SET "infoHash" = ?, "size" = ? WHERE id = ?' - - const options = { - type: Sequelize.QueryTypes.UPDATE, - replacements: [ infoHash, size, row['id'] ] - } - await sequelizeTypescript.query(queryUpdate, options) - } -} diff --git a/scripts/prune-storage.ts b/scripts/prune-storage.ts index 3012bdb94..d19594a60 100755 --- a/scripts/prune-storage.ts +++ b/scripts/prune-storage.ts @@ -2,7 +2,7 @@ import { map } from 'bluebird' import { readdir, remove, stat } from 'fs-extra' import { basename, join } from 'path' import { get, start } from 'prompt' -import { HLS_REDUNDANCY_DIRECTORY, HLS_STREAMING_PLAYLIST_DIRECTORY } from '@server/initializers/constants' +import { DIRECTORIES } from '@server/initializers/constants' import { VideoFileModel } from '@server/models/video/video-file' import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' import { uniqify } from '@shared/core-utils' @@ -37,9 +37,11 @@ async function run () { console.log('Detecting files to remove, it could take a while...') toDelete = toDelete.concat( - await pruneDirectory(CONFIG.STORAGE.VIDEOS_DIR, doesWebTorrentFileExist()), + await pruneDirectory(DIRECTORIES.VIDEOS.PUBLIC, doesWebTorrentFileExist()), + await pruneDirectory(DIRECTORIES.VIDEOS.PRIVATE, doesWebTorrentFileExist()), - await pruneDirectory(HLS_STREAMING_PLAYLIST_DIRECTORY, doesHLSPlaylistExist()), + await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()), + await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()), await pruneDirectory(CONFIG.STORAGE.TORRENTS_DIR, doesTorrentFileExist()), @@ -75,7 +77,7 @@ async function run () { } } -type ExistFun = (file: string) => Promise +type ExistFun = (file: string) => Promise | boolean async function pruneDirectory (directory: string, existFun: ExistFun) { const files = await readdir(directory) @@ -92,11 +94,21 @@ async function pruneDirectory (directory: string, existFun: ExistFun) { } function doesWebTorrentFileExist () { - return (filePath: string) => VideoFileModel.doesOwnedWebTorrentVideoFileExist(basename(filePath)) + return (filePath: string) => { + // Don't delete private directory + if (filePath === DIRECTORIES.VIDEOS.PRIVATE) return true + + return VideoFileModel.doesOwnedWebTorrentVideoFileExist(basename(filePath)) + } } function doesHLSPlaylistExist () { - return (hlsPath: string) => VideoStreamingPlaylistModel.doesOwnedHLSPlaylistExist(basename(hlsPath)) + return (hlsPath: string) => { + // Don't delete private directory + if (hlsPath === DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE) return true + + return VideoStreamingPlaylistModel.doesOwnedHLSPlaylistExist(basename(hlsPath)) + } } function doesTorrentFileExist () { @@ -127,8 +139,8 @@ async function doesRedundancyExist (filePath: string) { const isPlaylist = (await stat(filePath)).isDirectory() if (isPlaylist) { - // Don't delete HLS directory - if (filePath === HLS_REDUNDANCY_DIRECTORY) return true + // Don't delete HLS redundancy directory + if (filePath === DIRECTORIES.HLS_REDUNDANCY) return true const uuid = getUUIDFromFilename(filePath) const video = await VideoModel.loadWithFiles(uuid) diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts index 4e5333782..f3792bfc8 100644 --- a/server/controllers/api/server/debug.ts +++ b/server/controllers/api/server/debug.ts @@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { UserRight } from '../../../../shared/models/users' import { authenticate, ensureUserHasRight } from '../../../middlewares' import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler' +import { UpdateVideosScheduler } from '@server/lib/schedulers/update-videos-scheduler' const debugRouter = express.Router() @@ -45,6 +46,7 @@ async function runCommand (req: express.Request, res: express.Response) { 'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(), 'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(), 'process-video-viewers': () => VideoViewsManager.Instance.processViewerStats(), + 'process-update-videos-scheduler': () => UpdateVideosScheduler.Instance.execute(), 'process-video-channel-sync-latest': () => VideoChannelSyncLatestScheduler.Instance.execute() } diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index b301515df..ea081e5ab 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -41,6 +41,7 @@ import { ownershipVideoRouter } from './ownership' import { rateVideoRouter } from './rate' import { statsRouter } from './stats' import { studioRouter } from './studio' +import { tokenRouter } from './token' import { transcodingRouter } from './transcoding' import { updateRouter } from './update' import { uploadRouter } from './upload' @@ -63,6 +64,7 @@ videosRouter.use('/', uploadRouter) videosRouter.use('/', updateRouter) videosRouter.use('/', filesRouter) videosRouter.use('/', transcodingRouter) +videosRouter.use('/', tokenRouter) videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts new file mode 100644 index 000000000..009b6dfb6 --- /dev/null +++ b/server/controllers/api/videos/token.ts @@ -0,0 +1,33 @@ +import express from 'express' +import { VideoTokensManager } from '@server/lib/video-tokens-manager' +import { VideoToken } from '@shared/models' +import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' + +const tokenRouter = express.Router() + +tokenRouter.post('/:id/token', + authenticate, + asyncMiddleware(videosCustomGetValidator('only-video')), + generateToken +) + +// --------------------------------------------------------------------------- + +export { + tokenRouter +} + +// --------------------------------------------------------------------------- + +function generateToken (req: express.Request, res: express.Response) { + const video = res.locals.onlyVideo + + const { token, expires } = VideoTokensManager.Instance.create(video.uuid) + + return res.json({ + files: { + token, + expires + } + } as VideoToken) +} diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts index ab1a23d9a..0a910379a 100644 --- a/server/controllers/api/videos/update.ts +++ b/server/controllers/api/videos/update.ts @@ -1,12 +1,12 @@ import express from 'express' import { Transaction } from 'sequelize/types' import { changeVideoChannelShare } from '@server/lib/activitypub/share' -import { CreateJobArgument, JobQueue } from '@server/lib/job-queue' -import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' +import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' +import { setVideoPrivacy } from '@server/lib/video-privacy' import { openapiOperationDoc } from '@server/middlewares/doc' import { FilteredModelAttributes } from '@server/types' import { MVideoFullLight } from '@server/types/models' -import { HttpStatusCode, ManageVideoTorrentPayload, VideoUpdate } from '@shared/models' +import { HttpStatusCode, VideoUpdate } from '@shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { resetSequelizeInstance } from '../../../helpers/database-utils' import { createReqFiles } from '../../../helpers/express-utils' @@ -18,6 +18,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { VideoModel } from '../../../models/video/video' +import { VideoPathManager } from '@server/lib/video-path-manager' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -47,8 +48,8 @@ async function updateVideo (req: express.Request, res: express.Response) { const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON()) const videoInfoToUpdate: VideoUpdate = req.body - const wasConfidentialVideo = videoFromReq.isConfidential() const hadPrivacyForFederation = videoFromReq.hasPrivacyForFederation() + const oldPrivacy = videoFromReq.privacy const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ video: videoFromReq, @@ -57,12 +58,13 @@ async function updateVideo (req: express.Request, res: express.Response) { automaticallyGenerated: false }) + const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoFromReq.uuid) + try { const { videoInstanceUpdated, isNewVideo } = await sequelizeTypescript.transaction(async t => { // Refresh video since thumbnails to prevent concurrent updates const video = await VideoModel.loadFull(videoFromReq.id, t) - const sequelizeOptions = { transaction: t } const oldVideoChannel = video.VideoChannel const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes)[] = [ @@ -97,7 +99,7 @@ async function updateVideo (req: express.Request, res: express.Response) { await video.setAsRefreshed(t) } - const videoInstanceUpdated = await video.save(sequelizeOptions) as MVideoFullLight + const videoInstanceUpdated = await video.save({ transaction: t }) as MVideoFullLight // Thumbnail & preview updates? if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t) @@ -113,7 +115,9 @@ async function updateVideo (req: express.Request, res: express.Response) { await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t }) videoInstanceUpdated.VideoChannel = res.locals.videoChannel - if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) + if (hadPrivacyForFederation === true) { + await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) + } } // Schedule an update in the future? @@ -139,7 +143,12 @@ async function updateVideo (req: express.Request, res: express.Response) { Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res }) - await addVideoJobsAfterUpdate({ video: videoInstanceUpdated, videoInfoToUpdate, wasConfidentialVideo, isNewVideo }) + await addVideoJobsAfterUpdate({ + video: videoInstanceUpdated, + nameChanged: !!videoInfoToUpdate.name, + oldPrivacy, + isNewVideo + }) } catch (err) { // Force fields we want to update // If the transaction is retried, sequelize will think the object has not changed @@ -147,6 +156,8 @@ async function updateVideo (req: express.Request, res: express.Response) { resetSequelizeInstance(videoFromReq, videoFieldsSave) throw err + } finally { + videoFileLockReleaser() } return res.type('json') @@ -164,7 +175,7 @@ async function updateVideoPrivacy (options: { const isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy) const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10) - videoInstance.setPrivacy(newPrivacy) + setVideoPrivacy(videoInstance, newPrivacy) // Unfederate the video if the new privacy is not compatible with federation if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { @@ -185,50 +196,3 @@ function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: Vide return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction) } } - -async function addVideoJobsAfterUpdate (options: { - video: MVideoFullLight - videoInfoToUpdate: VideoUpdate - wasConfidentialVideo: boolean - isNewVideo: boolean -}) { - const { video, videoInfoToUpdate, wasConfidentialVideo, isNewVideo } = options - const jobs: CreateJobArgument[] = [] - - if (!video.isLive && videoInfoToUpdate.name) { - - for (const file of (video.VideoFiles || [])) { - const payload: ManageVideoTorrentPayload = { action: 'update-metadata', videoId: video.id, videoFileId: file.id } - - jobs.push({ type: 'manage-video-torrent', payload }) - } - - const hls = video.getHLSPlaylist() - - for (const file of (hls?.VideoFiles || [])) { - const payload: ManageVideoTorrentPayload = { action: 'update-metadata', streamingPlaylistId: hls.id, videoFileId: file.id } - - jobs.push({ type: 'manage-video-torrent', payload }) - } - } - - jobs.push({ - type: 'federate-video', - payload: { - videoUUID: video.uuid, - isNewVideo - } - }) - - if (wasConfidentialVideo) { - jobs.push({ - type: 'notify', - payload: { - action: 'new-video', - videoUUID: video.uuid - } - }) - } - - return JobQueue.Instance.createSequentialJobFlow(...jobs) -} diff --git a/server/controllers/download.ts b/server/controllers/download.ts index a270180c0..abd1df26f 100644 --- a/server/controllers/download.ts +++ b/server/controllers/download.ts @@ -7,7 +7,7 @@ import { VideoPathManager } from '@server/lib/video-path-manager' import { MStreamingPlaylist, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' import { HttpStatusCode, VideoStorage, VideoStreamingPlaylistType } from '@shared/models' import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants' -import { asyncMiddleware, videosDownloadValidator } from '../middlewares' +import { asyncMiddleware, optionalAuthenticate, videosDownloadValidator } from '../middlewares' const downloadRouter = express.Router() @@ -20,12 +20,14 @@ downloadRouter.use( downloadRouter.use( STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', + optionalAuthenticate, asyncMiddleware(videosDownloadValidator), asyncMiddleware(downloadVideoFile) ) downloadRouter.use( STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension', + optionalAuthenticate, asyncMiddleware(videosDownloadValidator), asyncMiddleware(downloadHLSVideoFile) ) diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 33c429eb1..dc091455a 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -1,20 +1,34 @@ import cors from 'cors' import express from 'express' -import { handleStaticError } from '@server/middlewares' +import { + asyncMiddleware, + ensureCanAccessPrivateVideoHLSFiles, + ensureCanAccessVideoPrivateWebTorrentFiles, + handleStaticError, + optionalAuthenticate +} from '@server/middlewares' import { CONFIG } from '../initializers/config' -import { HLS_STREAMING_PLAYLIST_DIRECTORY, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants' +import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants' const staticRouter = express.Router() // Cors is very important to let other servers access torrent and video files staticRouter.use(cors()) -// Videos path for webseed +// WebTorrent/Classic videos staticRouter.use( - STATIC_PATHS.WEBSEED, - express.static(CONFIG.STORAGE.VIDEOS_DIR, { fallthrough: false }), + STATIC_PATHS.PRIVATE_WEBSEED, + optionalAuthenticate, + asyncMiddleware(ensureCanAccessVideoPrivateWebTorrentFiles), + express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }), handleStaticError ) +staticRouter.use( + STATIC_PATHS.WEBSEED, + express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }), + handleStaticError +) + staticRouter.use( STATIC_PATHS.REDUNDANCY, express.static(CONFIG.STORAGE.REDUNDANCY_DIR, { fallthrough: false }), @@ -22,9 +36,16 @@ staticRouter.use( ) // HLS +staticRouter.use( + STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, + optionalAuthenticate, + asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles), + express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, { fallthrough: false }), + handleStaticError +) staticRouter.use( STATIC_PATHS.STREAMING_PLAYLISTS.HLS, - express.static(HLS_STREAMING_PLAYLIST_DIRECTORY, { fallthrough: false }), + express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, { fallthrough: false }), handleStaticError ) diff --git a/server/helpers/ffmpeg/ffmpeg-vod.ts b/server/helpers/ffmpeg/ffmpeg-vod.ts index 7a81a1313..d84703eb9 100644 --- a/server/helpers/ffmpeg/ffmpeg-vod.ts +++ b/server/helpers/ffmpeg/ffmpeg-vod.ts @@ -1,14 +1,15 @@ +import { MutexInterface } from 'async-mutex' import { Job } from 'bullmq' import { FfmpegCommand } from 'fluent-ffmpeg' import { readFile, writeFile } from 'fs-extra' import { dirname } from 'path' +import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' import { pick } from '@shared/core-utils' import { AvailableEncoders, VideoResolution } from '@shared/models' import { logger, loggerTagsFactory } from '../logger' import { getFFmpeg, runCommand } from './ffmpeg-commons' import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' import { computeFPS, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from './ffprobe-utils' -import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' const lTags = loggerTagsFactory('ffmpeg') @@ -22,6 +23,10 @@ interface BaseTranscodeVODOptions { inputPath: string outputPath: string + // Will be released after the ffmpeg started + // To prevent a bug where the input file does not exist anymore when running ffmpeg + inputFileMutexReleaser: MutexInterface.Releaser + availableEncoders: AvailableEncoders profile: string @@ -94,6 +99,12 @@ async function transcodeVOD (options: TranscodeVODOptions) { command = await builders[options.type](command, options) + command.on('start', () => { + setTimeout(() => { + options.inputFileMutexReleaser() + }, 1000) + }) + await runCommand({ command, job: options.job }) await fixHLSPlaylistIfNeeded(options) diff --git a/server/helpers/upload.ts b/server/helpers/upload.ts index 3cb17edd0..f5f476913 100644 --- a/server/helpers/upload.ts +++ b/server/helpers/upload.ts @@ -1,10 +1,10 @@ import { join } from 'path' -import { RESUMABLE_UPLOAD_DIRECTORY } from '../initializers/constants' +import { DIRECTORIES } from '@server/initializers/constants' function getResumableUploadPath (filename?: string) { - if (filename) return join(RESUMABLE_UPLOAD_DIRECTORY, filename) + if (filename) return join(DIRECTORIES.RESUMABLE_UPLOAD, filename) - return RESUMABLE_UPLOAD_DIRECTORY + return DIRECTORIES.RESUMABLE_UPLOAD } // --------------------------------------------------------------------------- diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts index 88bdb16b6..6d87c74f7 100644 --- a/server/helpers/webtorrent.ts +++ b/server/helpers/webtorrent.ts @@ -164,7 +164,10 @@ function generateMagnetUri ( ) { const xs = videoFile.getTorrentUrl() const announce = trackerUrls - let urlList = [ videoFile.getFileUrl(video) ] + + let urlList = video.requiresAuth(video.uuid) + ? [] + : [ videoFile.getFileUrl(video) ] const redundancies = videoFile.RedundancyVideos if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl)) @@ -240,6 +243,8 @@ function buildAnnounceList () { } function buildUrlList (video: MVideo, videoFile: MVideoFile) { + if (video.requiresAuth(video.uuid)) return [] + return [ videoFile.getFileUrl(video) ] } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index cab61948a..88bdd07fe 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -662,10 +662,15 @@ const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { // Express static paths (router) const STATIC_PATHS = { THUMBNAILS: '/static/thumbnails/', + WEBSEED: '/static/webseed/', + PRIVATE_WEBSEED: '/static/webseed/private/', + REDUNDANCY: '/static/redundancy/', + STREAMING_PLAYLISTS: { - HLS: '/static/streaming-playlists/hls' + HLS: '/static/streaming-playlists/hls', + PRIVATE_HLS: '/static/streaming-playlists/hls/private/' } } const STATIC_DOWNLOAD_PATHS = { @@ -745,12 +750,32 @@ const LRU_CACHE = { }, ACTOR_IMAGE_STATIC: { MAX_SIZE: 500 + }, + STATIC_VIDEO_FILES_RIGHTS_CHECK: { + MAX_SIZE: 5000, + TTL: parseDurationToMs('10 seconds') + }, + VIDEO_TOKENS: { + MAX_SIZE: 100_000, + TTL: parseDurationToMs('8 hours') } } -const RESUMABLE_UPLOAD_DIRECTORY = join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads') -const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') -const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') +const DIRECTORIES = { + RESUMABLE_UPLOAD: join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads'), + + HLS_STREAMING_PLAYLIST: { + PUBLIC: join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls'), + PRIVATE: join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls', 'private') + }, + + VIDEOS: { + PUBLIC: CONFIG.STORAGE.VIDEOS_DIR, + PRIVATE: join(CONFIG.STORAGE.VIDEOS_DIR, 'private') + }, + + HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') +} const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS @@ -971,9 +996,8 @@ export { PEERTUBE_VERSION, LAZY_STATIC_PATHS, SEARCH_INDEX, - RESUMABLE_UPLOAD_DIRECTORY, + DIRECTORIES, RESUMABLE_UPLOAD_SESSION_LIFETIME, - HLS_REDUNDANCY_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, ACTOR_IMAGES_SIZE, ACCEPT_HEADERS, @@ -1007,7 +1031,6 @@ export { VIDEO_FILTERS, ROUTE_CACHE_LIFETIME, SORTABLE_COLUMNS, - HLS_STREAMING_PLAYLIST_DIRECTORY, JOB_TTL, DEFAULT_THEME_NAME, NSFW_POLICY_TYPES, diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index b02be9567..f5d8eedf1 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts @@ -10,7 +10,7 @@ import { ApplicationModel } from '../models/application/application' import { OAuthClientModel } from '../models/oauth/oauth-client' import { applicationExist, clientsExist, usersExist } from './checker-after-init' import { CONFIG } from './config' -import { FILES_CACHE, HLS_STREAMING_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION, RESUMABLE_UPLOAD_DIRECTORY } from './constants' +import { DIRECTORIES, FILES_CACHE, LAST_MIGRATION_VERSION } from './constants' import { sequelizeTypescript } from './database' async function installApplication () { @@ -92,11 +92,13 @@ function createDirectoriesIfNotExist () { tasks.push(ensureDir(dir)) } - // Playlist directories - tasks.push(ensureDir(HLS_STREAMING_PLAYLIST_DIRECTORY)) + tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE)) + tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC)) + tasks.push(ensureDir(DIRECTORIES.VIDEOS.PUBLIC)) + tasks.push(ensureDir(DIRECTORIES.VIDEOS.PRIVATE)) // Resumable upload directory - tasks.push(ensureDir(RESUMABLE_UPLOAD_DIRECTORY)) + tasks.push(ensureDir(DIRECTORIES.RESUMABLE_UPLOAD)) return Promise.all(tasks) } diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index 35b05ec5a..bc0d4301f 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -95,14 +95,9 @@ async function handleOAuthToken (req: express.Request, options: { refreshTokenAu function handleOAuthAuthenticate ( req: express.Request, - res: express.Response, - authenticateInQuery = false + res: express.Response ) { - const options = authenticateInQuery - ? { allowBearerTokensInQueryString: true } - : {} - - return oAuthServer.authenticate(new Request(req), new Response(res), options) + return oAuthServer.authenticate(new Request(req), new Response(res)) } export { diff --git a/server/lib/job-queue/handlers/manage-video-torrent.ts b/server/lib/job-queue/handlers/manage-video-torrent.ts index 03aa414c9..425915c96 100644 --- a/server/lib/job-queue/handlers/manage-video-torrent.ts +++ b/server/lib/job-queue/handlers/manage-video-torrent.ts @@ -82,7 +82,7 @@ async function loadStreamingPlaylistOrLog (streamingPlaylistId: number) { async function loadFileOrLog (videoFileId: number) { if (!videoFileId) return undefined - const file = await VideoFileModel.loadWithVideo(videoFileId) + const file = await VideoFileModel.load(videoFileId) if (!file) { logger.debug('Do not process torrent for file %d: does not exist anymore.', videoFileId) diff --git a/server/lib/job-queue/handlers/move-to-object-storage.ts b/server/lib/job-queue/handlers/move-to-object-storage.ts index 28c3d325d..0b68555d1 100644 --- a/server/lib/job-queue/handlers/move-to-object-storage.ts +++ b/server/lib/job-queue/handlers/move-to-object-storage.ts @@ -3,10 +3,10 @@ import { remove } from 'fs-extra' import { join } from 'path' import { logger, loggerTagsFactory } from '@server/helpers/logger' import { updateTorrentMetadata } from '@server/helpers/webtorrent' -import { CONFIG } from '@server/initializers/config' import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants' import { storeHLSFileFromFilename, storeWebTorrentFile } from '@server/lib/object-storage' import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths' +import { VideoPathManager } from '@server/lib/video-path-manager' import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state' import { VideoModel } from '@server/models/video/video' import { VideoJobInfoModel } from '@server/models/video/video-job-info' @@ -72,9 +72,9 @@ async function moveWebTorrentFiles (video: MVideoWithAllFiles) { for (const file of video.VideoFiles) { if (file.storage !== VideoStorage.FILE_SYSTEM) continue - const fileUrl = await storeWebTorrentFile(file.filename) + const fileUrl = await storeWebTorrentFile(video, file) - const oldPath = join(CONFIG.STORAGE.VIDEOS_DIR, file.filename) + const oldPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, file) await onFileMoved({ videoOrPlaylist: video, file, fileUrl, oldPath }) } } diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 7dbffc955..c6263f55a 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -18,6 +18,7 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models' import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' import { logger, loggerTagsFactory } from '../../../helpers/logger' +import { VideoPathManager } from '@server/lib/video-path-manager' const lTags = loggerTagsFactory('live', 'job') @@ -205,18 +206,27 @@ async function assignReplayFilesToVideo (options: { const concatenatedTsFiles = await readdir(replayDirectory) for (const concatenatedTsFile of concatenatedTsFiles) { + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile) const probe = await ffprobePromise(concatenatedTsFilePath) const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) const { resolution } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe) - await generateHlsPlaylistResolutionFromTS({ - video, - concatenatedTsFilePath, - resolution, - isAAC: audioStream?.codec_name === 'aac' - }) + try { + await generateHlsPlaylistResolutionFromTS({ + video, + inputFileMutexReleaser, + concatenatedTsFilePath, + resolution, + isAAC: audioStream?.codec_name === 'aac' + }) + } catch (err) { + logger.error('Cannot generate HLS playlist resolution from TS files.', { err }) + } + + inputFileMutexReleaser() } return video diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index b0e92acf7..48c675678 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -94,15 +94,24 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, video: MV const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist() - await VideoPathManager.Instance.makeAvailableVideoFile(videoFileInput.withVideoOrPlaylist(videoOrStreamingPlaylist), videoInputPath => { - return generateHlsPlaylistResolution({ - video, - videoInputPath, - resolution: payload.resolution, - copyCodecs: payload.copyCodecs, - job + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + + try { + await videoFileInput.getVideo().reload() + + await VideoPathManager.Instance.makeAvailableVideoFile(videoFileInput.withVideoOrPlaylist(videoOrStreamingPlaylist), videoInputPath => { + return generateHlsPlaylistResolution({ + video, + videoInputPath, + inputFileMutexReleaser, + resolution: payload.resolution, + copyCodecs: payload.copyCodecs, + job + }) }) - }) + } finally { + inputFileMutexReleaser() + } logger.info('HLS transcoding job for %s ended.', video.uuid, lTags(video.uuid)) @@ -177,38 +186,44 @@ async function onVideoFirstWebTorrentTranscoding ( transcodeType: TranscodeVODOptionsType, user: MUserId ) { - const { resolution, audioStream } = await videoArg.probeMaxQualityFile() + const mutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid) - // Maybe the video changed in database, refresh it - const videoDatabase = await VideoModel.loadFull(videoArg.uuid) - // Video does not exist anymore - if (!videoDatabase) return undefined + try { + // Maybe the video changed in database, refresh it + const videoDatabase = await VideoModel.loadFull(videoArg.uuid) + // Video does not exist anymore + if (!videoDatabase) return undefined - // Generate HLS version of the original file - const originalFileHLSPayload = { - ...payload, + const { resolution, audioStream } = await videoDatabase.probeMaxQualityFile() - hasAudio: !!audioStream, - resolution: videoDatabase.getMaxQualityFile().resolution, - // If we quick transcoded original file, force transcoding for HLS to avoid some weird playback issues - copyCodecs: transcodeType !== 'quick-transcode', - isMaxQuality: true - } - const hasHls = await createHlsJobIfEnabled(user, originalFileHLSPayload) - const hasNewResolutions = await createLowerResolutionsJobs({ - video: videoDatabase, - user, - videoFileResolution: resolution, - hasAudio: !!audioStream, - type: 'webtorrent', - isNewVideo: payload.isNewVideo ?? true - }) + // Generate HLS version of the original file + const originalFileHLSPayload = { + ...payload, - await VideoJobInfoModel.decrease(videoDatabase.uuid, 'pendingTranscode') + hasAudio: !!audioStream, + resolution: videoDatabase.getMaxQualityFile().resolution, + // If we quick transcoded original file, force transcoding for HLS to avoid some weird playback issues + copyCodecs: transcodeType !== 'quick-transcode', + isMaxQuality: true + } + const hasHls = await createHlsJobIfEnabled(user, originalFileHLSPayload) + const hasNewResolutions = await createLowerResolutionsJobs({ + video: videoDatabase, + user, + videoFileResolution: resolution, + hasAudio: !!audioStream, + type: 'webtorrent', + isNewVideo: payload.isNewVideo ?? true + }) - // Move to next state if there are no other resolutions to generate - if (!hasHls && !hasNewResolutions) { - await retryTransactionWrapper(moveToNextState, { video: videoDatabase, isNewVideo: payload.isNewVideo }) + await VideoJobInfoModel.decrease(videoDatabase.uuid, 'pendingTranscode') + + // Move to next state if there are no other resolutions to generate + if (!hasHls && !hasNewResolutions) { + await retryTransactionWrapper(moveToNextState, { video: videoDatabase, isNewVideo: payload.isNewVideo }) + } + } finally { + mutexReleaser() } } diff --git a/server/lib/object-storage/videos.ts b/server/lib/object-storage/videos.ts index 62aae248b..e323baaa2 100644 --- a/server/lib/object-storage/videos.ts +++ b/server/lib/object-storage/videos.ts @@ -1,8 +1,9 @@ import { basename, join } from 'path' import { logger } from '@server/helpers/logger' import { CONFIG } from '@server/initializers/config' -import { MStreamingPlaylistVideo, MVideoFile } from '@server/types/models' +import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models' import { getHLSDirectory } from '../paths' +import { VideoPathManager } from '../video-path-manager' import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebTorrentObjectStorageKey } from './keys' import { listKeysOfPrefix, lTags, makeAvailable, removeObject, removePrefix, storeObject } from './shared' @@ -30,10 +31,10 @@ function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: string) // --------------------------------------------------------------------------- -function storeWebTorrentFile (filename: string) { +function storeWebTorrentFile (video: MVideo, file: MVideoFile) { return storeObject({ - inputPath: join(CONFIG.STORAGE.VIDEOS_DIR, filename), - objectStorageKey: generateWebTorrentObjectStorageKey(filename), + inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file), + objectStorageKey: generateWebTorrentObjectStorageKey(file.filename), bucketInfo: CONFIG.OBJECT_STORAGE.VIDEOS }) } diff --git a/server/lib/paths.ts b/server/lib/paths.ts index b29854700..470970f55 100644 --- a/server/lib/paths.ts +++ b/server/lib/paths.ts @@ -1,9 +1,10 @@ import { join } from 'path' import { CONFIG } from '@server/initializers/config' -import { HLS_REDUNDANCY_DIRECTORY, HLS_STREAMING_PLAYLIST_DIRECTORY, VIDEO_LIVE } from '@server/initializers/constants' +import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants' import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoUUID } from '@server/types/models' import { removeFragmentedMP4Ext } from '@shared/core-utils' import { buildUUID } from '@shared/extra-utils' +import { isVideoInPrivateDirectory } from './video-privacy' // ################## Video file name ################## @@ -17,20 +18,24 @@ function generateHLSVideoFilename (resolution: number) { // ################## Streaming playlist ################## -function getLiveDirectory (video: MVideoUUID) { +function getLiveDirectory (video: MVideo) { return getHLSDirectory(video) } -function getLiveReplayBaseDirectory (video: MVideoUUID) { +function getLiveReplayBaseDirectory (video: MVideo) { return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY) } -function getHLSDirectory (video: MVideoUUID) { - return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) +function getHLSDirectory (video: MVideo) { + if (isVideoInPrivateDirectory(video.privacy)) { + return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, video.uuid) + } + + return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, video.uuid) } function getHLSRedundancyDirectory (video: MVideoUUID) { - return join(HLS_REDUNDANCY_DIRECTORY, video.uuid) + return join(DIRECTORIES.HLS_REDUNDANCY, video.uuid) } function getHlsResolutionPlaylistFilename (videoFilename: string) { diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts index 5bfbc3cd2..30bf189db 100644 --- a/server/lib/schedulers/update-videos-scheduler.ts +++ b/server/lib/schedulers/update-videos-scheduler.ts @@ -1,11 +1,14 @@ import { VideoModel } from '@server/models/video/video' -import { MVideoFullLight } from '@server/types/models' +import { MScheduleVideoUpdate } from '@server/types/models' +import { VideoPrivacy, VideoState } from '@shared/models' import { logger } from '../../helpers/logger' import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' import { sequelizeTypescript } from '../../initializers/database' import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update' -import { federateVideoIfNeeded } from '../activitypub/videos' import { Notifier } from '../notifier' +import { addVideoJobsAfterUpdate } from '../video' +import { VideoPathManager } from '../video-path-manager' +import { setVideoPrivacy } from '../video-privacy' import { AbstractScheduler } from './abstract-scheduler' export class UpdateVideosScheduler extends AbstractScheduler { @@ -26,35 +29,54 @@ export class UpdateVideosScheduler extends AbstractScheduler { if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate() - const publishedVideos: MVideoFullLight[] = [] for (const schedule of schedules) { - await sequelizeTypescript.transaction(async t => { - const video = await VideoModel.loadFull(schedule.videoId, t) + const videoOnly = await VideoModel.load(schedule.videoId) + const mutexReleaser = await VideoPathManager.Instance.lockFiles(videoOnly.uuid) - logger.info('Executing scheduled video update on %s.', video.uuid) + try { + const { video, published } = await this.updateAVideo(schedule) - if (schedule.privacy) { - const wasConfidentialVideo = video.isConfidential() - const isNewVideo = video.isNewVideo(schedule.privacy) + if (published) Notifier.Instance.notifyOnVideoPublishedAfterScheduledUpdate(video) + } catch (err) { + logger.error('Cannot update video', { err }) + } - video.setPrivacy(schedule.privacy) - await video.save({ transaction: t }) - await federateVideoIfNeeded(video, isNewVideo, t) + mutexReleaser() + } + } - if (wasConfidentialVideo) { - publishedVideos.push(video) - } + private async updateAVideo (schedule: MScheduleVideoUpdate) { + let oldPrivacy: VideoPrivacy + let isNewVideo: boolean + let published = false + + const video = await sequelizeTypescript.transaction(async t => { + const video = await VideoModel.loadFull(schedule.videoId, t) + if (video.state === VideoState.TO_TRANSCODE) return + + logger.info('Executing scheduled video update on %s.', video.uuid) + + if (schedule.privacy) { + isNewVideo = video.isNewVideo(schedule.privacy) + oldPrivacy = video.privacy + + setVideoPrivacy(video, schedule.privacy) + await video.save({ transaction: t }) + + if (oldPrivacy === VideoPrivacy.PRIVATE) { + published = true } + } - await schedule.destroy({ transaction: t }) - }) - } + await schedule.destroy({ transaction: t }) - for (const v of publishedVideos) { - Notifier.Instance.notifyOnNewVideoIfNeeded(v) - Notifier.Instance.notifyOnVideoPublishedAfterScheduledUpdate(v) - } + return video + }) + + await addVideoJobsAfterUpdate({ video, oldPrivacy, isNewVideo, nameChanged: false }) + + return { video, published } } static get Instance () { diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts index 91c217615..78245fa6a 100644 --- a/server/lib/schedulers/videos-redundancy-scheduler.ts +++ b/server/lib/schedulers/videos-redundancy-scheduler.ts @@ -16,7 +16,7 @@ import { VideosRedundancyStrategy } from '../../../shared/models/redundancy' import { logger, loggerTagsFactory } from '../../helpers/logger' import { downloadWebTorrentVideo } from '../../helpers/webtorrent' import { CONFIG } from '../../initializers/config' -import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers/constants' +import { DIRECTORIES, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers/constants' import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send' import { getLocalVideoCacheFileActivityPubUrl, getLocalVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url' @@ -262,7 +262,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler { logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, strategy, lTags(video.uuid)) - const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid) + const destDirectory = join(DIRECTORIES.HLS_REDUNDANCY, video.uuid) const masterPlaylistUrl = playlist.getMasterPlaylistUrl(video) const maxSizeKB = this.getTotalFileSizes([], [ playlist ]) / 1000 diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts index 44e26754d..736e96e65 100644 --- a/server/lib/transcoding/transcoding.ts +++ b/server/lib/transcoding/transcoding.ts @@ -1,3 +1,4 @@ +import { MutexInterface } from 'async-mutex' import { Job } from 'bullmq' import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' import { basename, extname as extnameUtil, join } from 'path' @@ -6,11 +7,13 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { sequelizeTypescript } from '@server/initializers/database' import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' +import { pick } from '@shared/core-utils' import { VideoResolution, VideoStorage } from '../../../shared/models/videos' import { buildFileMetadata, canDoQuickTranscode, computeResolutionsToTranscode, + ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, transcodeVOD, @@ -33,7 +36,7 @@ import { VideoTranscodingProfilesManager } from './default-transcoding-profiles' */ // Optimize the original video file and replace it. The resolution is not changed. -function optimizeOriginalVideofile (options: { +async function optimizeOriginalVideofile (options: { video: MVideoFullLight inputVideoFile: MVideoFile job: Job @@ -43,49 +46,61 @@ function optimizeOriginalVideofile (options: { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' - return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => { - const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) - const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath) - ? 'quick-transcode' - : 'video' + try { + await video.reload() - const resolution = buildOriginalFileResolution(inputVideoFile.resolution) + const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video) - const transcodeOptions: TranscodeVODOptions = { - type: transcodeType, + const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => { + const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) - inputPath: videoInputPath, - outputPath: videoTranscodedPath, + const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath) + ? 'quick-transcode' + : 'video' - availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), - profile: CONFIG.TRANSCODING.PROFILE, + const resolution = buildOriginalFileResolution(inputVideoFile.resolution) - resolution, + const transcodeOptions: TranscodeVODOptions = { + type: transcodeType, - job - } + inputPath: videoInputPath, + outputPath: videoTranscodedPath, - // Could be very long! - await transcodeVOD(transcodeOptions) + inputFileMutexReleaser, - // Important to do this before getVideoFilename() to take in account the new filename - inputVideoFile.resolution = resolution - inputVideoFile.extname = newExtname - inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname) - inputVideoFile.storage = VideoStorage.FILE_SYSTEM + availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), + profile: CONFIG.TRANSCODING.PROFILE, - const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) + resolution, - const { videoFile } = await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) - await remove(videoInputPath) + job + } - return { transcodeType, videoFile } - }) + // Could be very long! + await transcodeVOD(transcodeOptions) + + // Important to do this before getVideoFilename() to take in account the new filename + inputVideoFile.resolution = resolution + inputVideoFile.extname = newExtname + inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname) + inputVideoFile.storage = VideoStorage.FILE_SYSTEM + + const { videoFile } = await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile) + await remove(videoInputPath) + + return { transcodeType, videoFile } + }) + + return result + } finally { + inputFileMutexReleaser() + } } // Transcode the original video file to a lower resolution compatible with WebTorrent -function transcodeNewWebTorrentResolution (options: { +async function transcodeNewWebTorrentResolution (options: { video: MVideoFullLight resolution: VideoResolution job: Job @@ -95,53 +110,68 @@ function transcodeNewWebTorrentResolution (options: { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' - return VideoPathManager.Instance.makeAvailableVideoFile(video.getMaxQualityFile().withVideoOrPlaylist(video), async videoInputPath => { - const newVideoFile = new VideoFileModel({ - resolution, - extname: newExtname, - filename: generateWebTorrentVideoFilename(resolution, newExtname), - size: 0, - videoId: video.id + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + + try { + await video.reload() + + const file = video.getMaxQualityFile().withVideoOrPlaylist(video) + + const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => { + const newVideoFile = new VideoFileModel({ + resolution, + extname: newExtname, + filename: generateWebTorrentVideoFilename(resolution, newExtname), + size: 0, + videoId: video.id + }) + + const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename) + + const transcodeOptions = resolution === VideoResolution.H_NOVIDEO + ? { + type: 'only-audio' as 'only-audio', + + inputPath: videoInputPath, + outputPath: videoTranscodedPath, + + inputFileMutexReleaser, + + availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), + profile: CONFIG.TRANSCODING.PROFILE, + + resolution, + + job + } + : { + type: 'video' as 'video', + inputPath: videoInputPath, + outputPath: videoTranscodedPath, + + inputFileMutexReleaser, + + availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), + profile: CONFIG.TRANSCODING.PROFILE, + + resolution, + + job + } + + await transcodeVOD(transcodeOptions) + + return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, newVideoFile) }) - const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile) - const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename) - - const transcodeOptions = resolution === VideoResolution.H_NOVIDEO - ? { - type: 'only-audio' as 'only-audio', - - inputPath: videoInputPath, - outputPath: videoTranscodedPath, - - availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), - profile: CONFIG.TRANSCODING.PROFILE, - - resolution, - - job - } - : { - type: 'video' as 'video', - inputPath: videoInputPath, - outputPath: videoTranscodedPath, - - availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), - profile: CONFIG.TRANSCODING.PROFILE, - - resolution, - - job - } - - await transcodeVOD(transcodeOptions) - - return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) - }) + return result + } finally { + inputFileMutexReleaser() + } } // Merge an image with an audio file to create a video -function mergeAudioVideofile (options: { +async function mergeAudioVideofile (options: { video: MVideoFullLight resolution: VideoResolution job: Job @@ -151,54 +181,67 @@ function mergeAudioVideofile (options: { const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const newExtname = '.mp4' - const inputVideoFile = video.getMinQualityFile() + const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) - return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async audioInputPath => { - const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) + try { + await video.reload() - // If the user updates the video preview during transcoding - const previewPath = video.getPreview().getPath() - const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath)) - await copyFile(previewPath, tmpPreviewPath) + const inputVideoFile = video.getMinQualityFile() - const transcodeOptions = { - type: 'merge-audio' as 'merge-audio', + const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video) - inputPath: tmpPreviewPath, - outputPath: videoTranscodedPath, + const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => { + const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) - availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), - profile: CONFIG.TRANSCODING.PROFILE, + // If the user updates the video preview during transcoding + const previewPath = video.getPreview().getPath() + const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath)) + await copyFile(previewPath, tmpPreviewPath) - audioPath: audioInputPath, - resolution, + const transcodeOptions = { + type: 'merge-audio' as 'merge-audio', - job - } + inputPath: tmpPreviewPath, + outputPath: videoTranscodedPath, - try { - await transcodeVOD(transcodeOptions) + inputFileMutexReleaser, - await remove(audioInputPath) - await remove(tmpPreviewPath) - } catch (err) { - await remove(tmpPreviewPath) - throw err - } + availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), + profile: CONFIG.TRANSCODING.PROFILE, - // Important to do this before getVideoFilename() to take in account the new file extension - inputVideoFile.extname = newExtname - inputVideoFile.resolution = resolution - inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname) + audioPath: audioInputPath, + resolution, - const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) - // ffmpeg generated a new video file, so update the video duration - // See https://trac.ffmpeg.org/ticket/5456 - video.duration = await getVideoStreamDuration(videoTranscodedPath) - await video.save() + job + } - return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) - }) + try { + await transcodeVOD(transcodeOptions) + + await remove(audioInputPath) + await remove(tmpPreviewPath) + } catch (err) { + await remove(tmpPreviewPath) + throw err + } + + // Important to do this before getVideoFilename() to take in account the new file extension + inputVideoFile.extname = newExtname + inputVideoFile.resolution = resolution + inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname) + + // ffmpeg generated a new video file, so update the video duration + // See https://trac.ffmpeg.org/ticket/5456 + video.duration = await getVideoStreamDuration(videoTranscodedPath) + await video.save() + + return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile) + }) + + return result + } finally { + inputFileMutexReleaser() + } } // Concat TS segments from a live video to a fragmented mp4 HLS playlist @@ -207,13 +250,13 @@ async function generateHlsPlaylistResolutionFromTS (options: { concatenatedTsFilePath: string resolution: VideoResolution isAAC: boolean + inputFileMutexReleaser: MutexInterface.Releaser }) { return generateHlsPlaylistCommon({ - video: options.video, - resolution: options.resolution, - inputPath: options.concatenatedTsFilePath, type: 'hls-from-ts' as 'hls-from-ts', - isAAC: options.isAAC + inputPath: options.concatenatedTsFilePath, + + ...pick(options, [ 'video', 'resolution', 'inputFileMutexReleaser', 'isAAC' ]) }) } @@ -223,15 +266,14 @@ function generateHlsPlaylistResolution (options: { videoInputPath: string resolution: VideoResolution copyCodecs: boolean + inputFileMutexReleaser: MutexInterface.Releaser job?: Job }) { return generateHlsPlaylistCommon({ - video: options.video, - resolution: options.resolution, - copyCodecs: options.copyCodecs, - inputPath: options.videoInputPath, type: 'hls' as 'hls', - job: options.job + inputPath: options.videoInputPath, + + ...pick(options, [ 'video', 'resolution', 'copyCodecs', 'inputFileMutexReleaser', 'job' ]) }) } @@ -251,27 +293,39 @@ async function onWebTorrentVideoFileTranscoding ( video: MVideoFullLight, videoFile: MVideoFile, transcodingPath: string, - outputPath: string + newVideoFile: MVideoFile ) { - const stats = await stat(transcodingPath) - const fps = await getVideoStreamFPS(transcodingPath) - const metadata = await buildFileMetadata(transcodingPath) + const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) - await move(transcodingPath, outputPath, { overwrite: true }) + try { + await video.reload() - videoFile.size = stats.size - videoFile.fps = fps - videoFile.metadata = metadata + const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile) - await createTorrentAndSetInfoHash(video, videoFile) + const stats = await stat(transcodingPath) - const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) - if (oldFile) await video.removeWebTorrentFile(oldFile) + const probe = await ffprobePromise(transcodingPath) + const fps = await getVideoStreamFPS(transcodingPath, probe) + const metadata = await buildFileMetadata(transcodingPath, probe) - await VideoFileModel.customUpsert(videoFile, 'video', undefined) - video.VideoFiles = await video.$get('VideoFiles') + await move(transcodingPath, outputPath, { overwrite: true }) - return { video, videoFile } + videoFile.size = stats.size + videoFile.fps = fps + videoFile.metadata = metadata + + await createTorrentAndSetInfoHash(video, videoFile) + + const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) + if (oldFile) await video.removeWebTorrentFile(oldFile) + + await VideoFileModel.customUpsert(videoFile, 'video', undefined) + video.VideoFiles = await video.$get('VideoFiles') + + return { video, videoFile } + } finally { + mutexReleaser() + } } async function generateHlsPlaylistCommon (options: { @@ -279,12 +333,15 @@ async function generateHlsPlaylistCommon (options: { video: MVideo inputPath: string resolution: VideoResolution + + inputFileMutexReleaser: MutexInterface.Releaser + copyCodecs?: boolean isAAC?: boolean job?: Job }) { - const { type, video, inputPath, resolution, copyCodecs, isAAC, job } = options + const { type, video, inputPath, resolution, copyCodecs, isAAC, job, inputFileMutexReleaser } = options const transcodeDirectory = CONFIG.STORAGE.TMP_DIR const videoTranscodedBasePath = join(transcodeDirectory, type) @@ -308,6 +365,8 @@ async function generateHlsPlaylistCommon (options: { isAAC, + inputFileMutexReleaser, + hlsPlaylist: { videoFilename }, @@ -333,40 +392,54 @@ async function generateHlsPlaylistCommon (options: { videoStreamingPlaylistId: playlist.id }) - const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile) - await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video)) + const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) - // Move playlist file - const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, resolutionPlaylistFilename) - await move(resolutionPlaylistFileTranscodePath, resolutionPlaylistPath, { overwrite: true }) - // Move video file - await move(join(videoTranscodedBasePath, videoFilename), videoFilePath, { overwrite: true }) + try { + // VOD transcoding is a long task, refresh video attributes + await video.reload() - // Update video duration if it was not set (in case of a live for example) - if (!video.duration) { - video.duration = await getVideoStreamDuration(videoFilePath) - await video.save() + const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile) + await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video)) + + // Move playlist file + const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, resolutionPlaylistFilename) + await move(resolutionPlaylistFileTranscodePath, resolutionPlaylistPath, { overwrite: true }) + // Move video file + await move(join(videoTranscodedBasePath, videoFilename), videoFilePath, { overwrite: true }) + + // Update video duration if it was not set (in case of a live for example) + if (!video.duration) { + video.duration = await getVideoStreamDuration(videoFilePath) + await video.save() + } + + const stats = await stat(videoFilePath) + + newVideoFile.size = stats.size + newVideoFile.fps = await getVideoStreamFPS(videoFilePath) + newVideoFile.metadata = await buildFileMetadata(videoFilePath) + + await createTorrentAndSetInfoHash(playlist, newVideoFile) + + const oldFile = await VideoFileModel.loadHLSFile({ + playlistId: playlist.id, + fps: newVideoFile.fps, + resolution: newVideoFile.resolution + }) + + if (oldFile) { + await video.removeStreamingPlaylistVideoFile(playlist, oldFile) + await oldFile.destroy() + } + + const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) + + await updatePlaylistAfterFileChange(video, playlist) + + return { resolutionPlaylistPath, videoFile: savedVideoFile } + } finally { + mutexReleaser() } - - const stats = await stat(videoFilePath) - - newVideoFile.size = stats.size - newVideoFile.fps = await getVideoStreamFPS(videoFilePath) - newVideoFile.metadata = await buildFileMetadata(videoFilePath) - - await createTorrentAndSetInfoHash(playlist, newVideoFile) - - const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, fps: newVideoFile.fps, resolution: newVideoFile.resolution }) - if (oldFile) { - await video.removeStreamingPlaylistVideoFile(playlist, oldFile) - await oldFile.destroy() - } - - const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) - - await updatePlaylistAfterFileChange(video, playlist) - - return { resolutionPlaylistPath, videoFile: savedVideoFile } } function buildOriginalFileResolution (inputResolution: number) { diff --git a/server/lib/video-path-manager.ts b/server/lib/video-path-manager.ts index c3f55fd95..9953cae5d 100644 --- a/server/lib/video-path-manager.ts +++ b/server/lib/video-path-manager.ts @@ -1,29 +1,31 @@ +import { Mutex } from 'async-mutex' import { remove } from 'fs-extra' import { extname, join } from 'path' +import { logger, loggerTagsFactory } from '@server/helpers/logger' import { extractVideo } from '@server/helpers/video' import { CONFIG } from '@server/initializers/config' -import { - MStreamingPlaylistVideo, - MVideo, - MVideoFile, - MVideoFileStreamingPlaylistVideo, - MVideoFileVideo, - MVideoUUID -} from '@server/types/models' +import { DIRECTORIES } from '@server/initializers/constants' +import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '@server/types/models' import { buildUUID } from '@shared/extra-utils' import { VideoStorage } from '@shared/models' import { makeHLSFileAvailable, makeWebTorrentFileAvailable } from './object-storage' import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths' +import { isVideoInPrivateDirectory } from './video-privacy' type MakeAvailableCB = (path: string) => Promise | T +const lTags = loggerTagsFactory('video-path-manager') + class VideoPathManager { private static instance: VideoPathManager + // Key is a video UUID + private readonly videoFileMutexStore = new Map() + private constructor () {} - getFSHLSOutputPath (video: MVideoUUID, filename?: string) { + getFSHLSOutputPath (video: MVideo, filename?: string) { const base = getHLSDirectory(video) if (!filename) return base @@ -41,13 +43,17 @@ class VideoPathManager { } getFSVideoFileOutputPath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { - if (videoFile.isHLS()) { - const video = extractVideo(videoOrPlaylist) + const video = extractVideo(videoOrPlaylist) + if (videoFile.isHLS()) { return join(getHLSDirectory(video), videoFile.filename) } - return join(CONFIG.STORAGE.VIDEOS_DIR, videoFile.filename) + if (isVideoInPrivateDirectory(video.privacy)) { + return join(DIRECTORIES.VIDEOS.PRIVATE, videoFile.filename) + } + + return join(DIRECTORIES.VIDEOS.PUBLIC, videoFile.filename) } async makeAvailableVideoFile (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB) { @@ -113,6 +119,27 @@ class VideoPathManager { ) } + async lockFiles (videoUUID: string) { + if (!this.videoFileMutexStore.has(videoUUID)) { + this.videoFileMutexStore.set(videoUUID, new Mutex()) + } + + const mutex = this.videoFileMutexStore.get(videoUUID) + const releaser = await mutex.acquire() + + logger.debug('Locked files of %s.', videoUUID, lTags(videoUUID)) + + return releaser + } + + unlockFiles (videoUUID: string) { + const mutex = this.videoFileMutexStore.get(videoUUID) + + mutex.release() + + logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID)) + } + private async makeAvailableFactory (method: () => Promise | string, clean: boolean, cb: MakeAvailableCB) { let result: T diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts new file mode 100644 index 000000000..1a4a5a22d --- /dev/null +++ b/server/lib/video-privacy.ts @@ -0,0 +1,96 @@ +import { move } from 'fs-extra' +import { join } from 'path' +import { logger } from '@server/helpers/logger' +import { DIRECTORIES } from '@server/initializers/constants' +import { MVideo, MVideoFullLight } from '@server/types/models' +import { VideoPrivacy } from '@shared/models' + +function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { + if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { + video.publishedAt = new Date() + } + + video.privacy = newPrivacy +} + +function isVideoInPrivateDirectory (privacy: VideoPrivacy) { + return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL +} + +function isVideoInPublicDirectory (privacy: VideoPrivacy) { + return !isVideoInPrivateDirectory(privacy) +} + +async function moveFilesIfPrivacyChanged (video: MVideoFullLight, oldPrivacy: VideoPrivacy) { + // Now public, previously private + if (isVideoInPublicDirectory(video.privacy) && isVideoInPrivateDirectory(oldPrivacy)) { + await moveFiles({ type: 'private-to-public', video }) + + return true + } + + // Now private, previously public + if (isVideoInPrivateDirectory(video.privacy) && isVideoInPublicDirectory(oldPrivacy)) { + await moveFiles({ type: 'public-to-private', video }) + + return true + } + + return false +} + +export { + setVideoPrivacy, + + isVideoInPrivateDirectory, + isVideoInPublicDirectory, + + moveFilesIfPrivacyChanged +} + +// --------------------------------------------------------------------------- + +async function moveFiles (options: { + type: 'private-to-public' | 'public-to-private' + video: MVideoFullLight +}) { + const { type, video } = options + + const directories = type === 'private-to-public' + ? { + webtorrent: { old: DIRECTORIES.VIDEOS.PRIVATE, new: DIRECTORIES.VIDEOS.PUBLIC }, + hls: { old: DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, new: DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC } + } + : { + webtorrent: { old: DIRECTORIES.VIDEOS.PUBLIC, new: DIRECTORIES.VIDEOS.PRIVATE }, + hls: { old: DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, new: DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE } + } + + for (const file of video.VideoFiles) { + const source = join(directories.webtorrent.old, file.filename) + const destination = join(directories.webtorrent.new, file.filename) + + try { + logger.info('Moving WebTorrent files of %s after privacy change (%s -> %s).', video.uuid, source, destination) + + await move(source, destination) + } catch (err) { + logger.error('Cannot move webtorrent file %s to %s after privacy change', source, destination, { err }) + } + } + + const hls = video.getHLSPlaylist() + + if (hls) { + const source = join(directories.hls.old, video.uuid) + const destination = join(directories.hls.new, video.uuid) + + try { + logger.info('Moving HLS files of %s after privacy change (%s -> %s).', video.uuid, source, destination) + + await move(source, destination) + } catch (err) { + logger.error('Cannot move HLS file %s to %s after privacy change', source, destination, { err }) + } + } +} diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts new file mode 100644 index 000000000..c43085d16 --- /dev/null +++ b/server/lib/video-tokens-manager.ts @@ -0,0 +1,49 @@ +import LRUCache from 'lru-cache' +import { LRU_CACHE } from '@server/initializers/constants' +import { buildUUID } from '@shared/extra-utils' + +// --------------------------------------------------------------------------- +// Create temporary tokens that can be used as URL query parameters to access video static files +// --------------------------------------------------------------------------- + +class VideoTokensManager { + + private static instance: VideoTokensManager + + private readonly lruCache = new LRUCache({ + max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, + ttl: LRU_CACHE.VIDEO_TOKENS.TTL + }) + + private constructor () {} + + create (videoUUID: string) { + const token = buildUUID() + + const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) + + this.lruCache.set(token, videoUUID) + + return { token, expires } + } + + hasToken (options: { + token: string + videoUUID: string + }) { + const value = this.lruCache.get(options.token) + if (!value) return false + + return value === options.videoUUID + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + VideoTokensManager +} diff --git a/server/lib/video.ts b/server/lib/video.ts index 6c4f3ce7b..aacc41a7a 100644 --- a/server/lib/video.ts +++ b/server/lib/video.ts @@ -7,10 +7,11 @@ import { TagModel } from '@server/models/video/tag' import { VideoModel } from '@server/models/video/video' import { VideoJobInfoModel } from '@server/models/video/video-job-info' import { FilteredModelAttributes } from '@server/types' -import { MThumbnail, MUserId, MVideoFile, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models' -import { ThumbnailType, VideoCreate, VideoPrivacy, VideoState, VideoTranscodingPayload } from '@shared/models' -import { CreateJobOptions } from './job-queue/job-queue' +import { MThumbnail, MUserId, MVideoFile, MVideoFullLight, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models' +import { ManageVideoTorrentPayload, ThumbnailType, VideoCreate, VideoPrivacy, VideoState, VideoTranscodingPayload } from '@shared/models' +import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-queue' import { updateVideoMiniatureFromExisting } from './thumbnail' +import { moveFilesIfPrivacyChanged } from './video-privacy' function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes { return { @@ -177,6 +178,59 @@ const getCachedVideoDuration = memoizee(getVideoDuration, { // --------------------------------------------------------------------------- +async function addVideoJobsAfterUpdate (options: { + video: MVideoFullLight + isNewVideo: boolean + + nameChanged: boolean + oldPrivacy: VideoPrivacy +}) { + const { video, nameChanged, oldPrivacy, isNewVideo } = options + const jobs: CreateJobArgument[] = [] + + const filePathChanged = await moveFilesIfPrivacyChanged(video, oldPrivacy) + + if (!video.isLive && (nameChanged || filePathChanged)) { + for (const file of (video.VideoFiles || [])) { + const payload: ManageVideoTorrentPayload = { action: 'update-metadata', videoId: video.id, videoFileId: file.id } + + jobs.push({ type: 'manage-video-torrent', payload }) + } + + const hls = video.getHLSPlaylist() + + for (const file of (hls?.VideoFiles || [])) { + const payload: ManageVideoTorrentPayload = { action: 'update-metadata', streamingPlaylistId: hls.id, videoFileId: file.id } + + jobs.push({ type: 'manage-video-torrent', payload }) + } + } + + jobs.push({ + type: 'federate-video', + payload: { + videoUUID: video.uuid, + isNewVideo + } + }) + + const wasConfidentialVideo = new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ]).has(oldPrivacy) + + if (wasConfidentialVideo) { + jobs.push({ + type: 'notify', + payload: { + action: 'new-video', + videoUUID: video.uuid + } + }) + } + + return JobQueue.Instance.createSequentialJobFlow(...jobs) +} + +// --------------------------------------------------------------------------- + export { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, @@ -185,5 +239,6 @@ export { buildTranscodingJob, buildMoveToObjectStorageJob, getTranscodingJobPriority, + addVideoJobsAfterUpdate, getCachedVideoDuration } diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts index 904d47efd..e6025c8ce 100644 --- a/server/middlewares/auth.ts +++ b/server/middlewares/auth.ts @@ -5,8 +5,8 @@ import { HttpStatusCode } from '../../shared/models/http/http-error-codes' import { logger } from '../helpers/logger' import { handleOAuthAuthenticate } from '../lib/auth/oauth' -function authenticate (req: express.Request, res: express.Response, next: express.NextFunction, authenticateInQuery = false) { - handleOAuthAuthenticate(req, res, authenticateInQuery) +function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { + handleOAuthAuthenticate(req, res) .then((token: any) => { res.locals.oauth = { token } res.locals.authenticated = true @@ -47,7 +47,7 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) { .catch(err => logger.error('Cannot get access token.', { err })) } -function authenticatePromise (req: express.Request, res: express.Response, authenticateInQuery = false) { +function authenticatePromise (req: express.Request, res: express.Response) { return new Promise(resolve => { // Already authenticated? (or tried to) if (res.locals.oauth?.token.User) return resolve() @@ -59,7 +59,7 @@ function authenticatePromise (req: express.Request, res: express.Response, authe }) } - authenticate(req, res, () => resolve(), authenticateInQuery) + authenticate(req, res, () => resolve()) }) } diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index ffadb3b49..899da229a 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -1,7 +1,6 @@ -export * from './activitypub' -export * from './videos' export * from './abuse' export * from './account' +export * from './activitypub' export * from './actor-image' export * from './blocklist' export * from './bulk' @@ -10,8 +9,8 @@ export * from './express' export * from './feeds' export * from './follows' export * from './jobs' -export * from './metrics' export * from './logs' +export * from './metrics' export * from './oembed' export * from './pagination' export * from './plugins' @@ -19,9 +18,11 @@ export * from './redundancy' export * from './search' export * from './server' export * from './sort' +export * from './static' export * from './themes' export * from './user-history' export * from './user-notifications' export * from './user-subscriptions' export * from './users' +export * from './videos' export * from './webfinger' diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index e3a98c58f..c29751eca 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express' -import { isUUIDValid } from '@server/helpers/custom-validators/misc' import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' import { isAbleToUploadVideo } from '@server/lib/user' +import { VideoTokensManager } from '@server/lib/video-tokens-manager' import { authenticatePromise } from '@server/middlewares/auth' import { VideoModel } from '@server/models/video/video' import { VideoChannelModel } from '@server/models/video/video-channel' @@ -108,26 +108,21 @@ async function checkCanSeeVideo (options: { res: Response paramId: string video: MVideo - authenticateInQuery?: boolean // default false }) { - const { req, res, video, paramId, authenticateInQuery = false } = options + const { req, res, video, paramId } = options - if (video.requiresAuth()) { - return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) + if (video.requiresAuth(paramId)) { + return checkCanSeeAuthVideo(req, res, video) } - if (video.privacy === VideoPrivacy.UNLISTED) { - if (isUUIDValid(paramId)) return true - - return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) + if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { + return true } - if (video.privacy === VideoPrivacy.PUBLIC) return true - - throw new Error('Fatal error when checking video right ' + video.url) + throw new Error('Unknown video privacy when checking video right ' + video.url) } -async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights, authenticateInQuery = false) { +async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { const fail = () => { res.fail({ status: HttpStatusCode.FORBIDDEN_403, @@ -137,7 +132,7 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI return false } - await authenticatePromise(req, res, authenticateInQuery) + await authenticatePromise(req, res) const user = res.locals.oauth?.token.User if (!user) return fail() @@ -173,6 +168,36 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI // --------------------------------------------------------------------------- +async function checkCanAccessVideoStaticFiles (options: { + video: MVideo + req: Request + res: Response + paramId: string +}) { + const { video, req, res, paramId } = options + + if (res.locals.oauth?.token.User) { + return checkCanSeeVideo(options) + } + + if (!video.requiresAuth(paramId)) return true + + const videoFileToken = req.query.videoFileToken + if (!videoFileToken) { + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { + return true + } + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false +} + +// --------------------------------------------------------------------------- + function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { // Retrieve the user who did the request if (onlyOwned && video.isOwned() === false) { @@ -220,6 +245,7 @@ export { doesVideoExist, doesVideoFileOfVideoExist, + checkCanAccessVideoStaticFiles, checkUserCanManageVideo, checkCanSeeVideo, checkUserQuota diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts new file mode 100644 index 000000000..ff9e6ae6e --- /dev/null +++ b/server/middlewares/validators/static.ts @@ -0,0 +1,131 @@ +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 { logger } from '@server/helpers/logger' +import { LRU_CACHE } from '@server/initializers/constants' +import { VideoModel } from '@server/models/video/video' +import { VideoFileModel } from '@server/models/video/video-file' +import { HttpStatusCode } from '@shared/models' +import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' + +const staticFileTokenBypass = new LRUCache({ + max: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.MAX_SIZE, + ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL +}) + +const ensureCanAccessVideoPrivateWebTorrentFiles = [ + query('videoFileToken').optional().custom(exists), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const token = extractTokenOrDie(req, res) + if (!token) return + + const cacheKey = token + '-' + req.originalUrl + + if (staticFileTokenBypass.has(cacheKey)) { + const allowedFromCache = staticFileTokenBypass.get(cacheKey) + + if (allowedFromCache === true) return next() + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const allowed = await isWebTorrentAllowed(req, res) + + staticFileTokenBypass.set(cacheKey, allowed) + + if (allowed !== true) return + + return next() + } +] + +const ensureCanAccessPrivateVideoHLSFiles = [ + query('videoFileToken').optional().custom(exists), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const videoUUID = basename(dirname(req.originalUrl)) + + if (!isUUIDValid(videoUUID)) { + logger.debug('Path does not contain valid video UUID to serve static file %s', req.originalUrl) + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const token = extractTokenOrDie(req, res) + if (!token) return + + const cacheKey = token + '-' + videoUUID + + if (staticFileTokenBypass.has(cacheKey)) { + const allowedFromCache = staticFileTokenBypass.get(cacheKey) + + if (allowedFromCache === true) return next() + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const allowed = await isHLSAllowed(req, res, videoUUID) + + staticFileTokenBypass.set(cacheKey, allowed) + + if (allowed !== true) return + + return next() + } +] + +export { + ensureCanAccessVideoPrivateWebTorrentFiles, + ensureCanAccessPrivateVideoHLSFiles +} + +// --------------------------------------------------------------------------- + +async function isWebTorrentAllowed (req: express.Request, res: express.Response) { + const filename = basename(req.path) + + const file = await VideoFileModel.loadWithVideoByFilename(filename) + if (!file) { + logger.debug('Unknown static file %s to serve', req.originalUrl, { filename }) + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + const video = file.getVideo() + + return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) +} + +async function isHLSAllowed (req: express.Request, res: express.Response, videoUUID: string) { + const video = await VideoModel.load(videoUUID) + + if (!video) { + logger.debug('Unknown static file %s to serve', req.originalUrl, { videoUUID }) + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) +} + +function extractTokenOrDie (req: express.Request, res: express.Response) { + const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken + + if (!token) { + return res.fail({ + message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', + status: HttpStatusCode.FORBIDDEN_403 + }) + } + + return token +} diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 7fd2b03d1..e29eb4a32 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -7,7 +7,7 @@ import { getServerActor } from '@server/models/application/application' import { ExpressPromiseHandler } from '@server/types/express-handler' import { MUserAccountId, MVideoFullLight } from '@server/types/models' import { arrayify, getAllPrivacies } from '@shared/core-utils' -import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models' +import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' import { exists, isBooleanValid, @@ -48,6 +48,7 @@ import { Hooks } from '../../../lib/plugins/hooks' import { VideoModel } from '../../../models/video/video' import { areValidationErrors, + checkCanAccessVideoStaticFiles, checkCanSeeVideo, checkUserCanManageVideo, checkUserQuota, @@ -232,6 +233,11 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) + const video = getVideoWithAttributes(res) + if (req.body.privacy && video.isLive && video.state !== VideoState.WAITING_FOR_LIVE) { + return res.fail({ message: 'Cannot update privacy of a live that has already started' }) + } + // 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.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) @@ -271,10 +277,7 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R }) } -const videosCustomGetValidator = ( - fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes', - authenticateInQuery = false -) => { +const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes') => { return [ isValidVideoIdParam('id'), @@ -287,7 +290,7 @@ const videosCustomGetValidator = ( const video = getVideoWithAttributes(res) as MVideoFullLight - if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return + if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return return next() } @@ -295,7 +298,6 @@ const videosCustomGetValidator = ( } const videosGetValidator = videosCustomGetValidator('all') -const videosDownloadValidator = videosCustomGetValidator('all', true) const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ isValidVideoIdParam('id'), @@ -311,6 +313,21 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ } ]) +const videosDownloadValidator = [ + isValidVideoIdParam('id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.id, res, 'all')) return + + const video = getVideoWithAttributes(res) + + if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return + + return next() + } +] + const videosRemoveValidator = [ isValidVideoIdParam('id'), @@ -372,7 +389,7 @@ function getCommonVideoEditAttributes () { .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'), body('privacy') .optional() - .customSanitizer(toValueOrNull) + .customSanitizer(toIntOrNull) .custom(isVideoPrivacyValid), body('description') .optional() diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts index e1b0eb610..76745f4b5 100644 --- a/server/models/video/formatter/video-format-utils.ts +++ b/server/models/video/formatter/video-format-utils.ts @@ -34,6 +34,7 @@ import { import { MServer, MStreamingPlaylistRedundanciesOpt, + MUserId, MVideo, MVideoAP, MVideoFile, @@ -245,8 +246,12 @@ function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) { function videoFilesModelToFormattedJSON ( video: MVideoFormattable, videoFiles: MVideoFileRedundanciesOpt[], - includeMagnet = true + options: { + includeMagnet?: boolean // default true + } = {} ): VideoFile[] { + const { includeMagnet = true } = options + const trackerUrls = includeMagnet ? video.getTrackerUrls() : [] @@ -281,11 +286,14 @@ function videoFilesModelToFormattedJSON ( }) } -function addVideoFilesInAPAcc ( - acc: ActivityUrlObject[] | ActivityTagObject[], - video: MVideo, +function addVideoFilesInAPAcc (options: { + acc: ActivityUrlObject[] | ActivityTagObject[] + video: MVideo files: MVideoFile[] -) { + user?: MUserId +}) { + const { acc, video, files } = options + const trackerUrls = video.getTrackerUrls() const sortedFiles = (files || []) @@ -370,7 +378,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { } ] - addVideoFilesInAPAcc(url, video, video.VideoFiles || []) + addVideoFilesInAPAcc({ acc: url, video, files: video.VideoFiles || [] }) for (const playlist of (video.VideoStreamingPlaylists || [])) { const tag = playlist.p2pMediaLoaderInfohashes @@ -382,7 +390,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { href: playlist.getSha256SegmentsUrl(video) }) - addVideoFilesInAPAcc(tag, video, playlist.VideoFiles || []) + addVideoFilesInAPAcc({ acc: tag, video, files: playlist.VideoFiles || [] }) url.push({ type: 'Link', diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index d4f07f85f..1a608932f 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts @@ -24,6 +24,7 @@ import { extractVideo } from '@server/helpers/video' import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' import { getHLSPublicFileUrl, getWebTorrentPublicFileUrl } from '@server/lib/object-storage' import { getFSTorrentFilePath } from '@server/lib/paths' +import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models' import { VideoResolution, VideoStorage } from '@shared/models' import { AttributesOnly } from '@shared/typescript-utils' @@ -295,6 +296,16 @@ export class VideoFileModel extends Model return VideoFileModel.findOne(query) } + static loadWithVideoByFilename (filename: string): Promise { + const query = { + where: { + filename + } + } + + return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query) + } + static loadWithVideoOrPlaylistByTorrentFilename (filename: string) { const query = { where: { @@ -305,6 +316,10 @@ export class VideoFileModel extends Model return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query) } + static load (id: number): Promise { + return VideoFileModel.findByPk(id) + } + static loadWithMetadata (id: number) { return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id) } @@ -467,7 +482,7 @@ export class VideoFileModel extends Model } getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo { - if (this.videoId) return (this as MVideoFileVideo).Video + if (this.videoId || (this as MVideoFileVideo).Video) return (this as MVideoFileVideo).Video return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist } @@ -508,7 +523,17 @@ export class VideoFileModel extends Model } getFileStaticPath (video: MVideo) { - if (this.isHLS()) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename) + if (this.isHLS()) { + if (isVideoInPrivateDirectory(video.privacy)) { + return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.filename) + } + + return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename) + } + + if (isVideoInPrivateDirectory(video.privacy)) { + return join(STATIC_PATHS.PRIVATE_WEBSEED, this.filename) + } return join(STATIC_PATHS.WEBSEED, this.filename) } diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts index 2b6771f27..b919046ed 100644 --- a/server/models/video/video-streaming-playlist.ts +++ b/server/models/video/video-streaming-playlist.ts @@ -17,6 +17,7 @@ import { } from 'sequelize-typescript' import { getHLSPublicFileUrl } from '@server/lib/object-storage' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths' +import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' import { VideoFileModel } from '@server/models/video/video-file' import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models' import { sha1 } from '@shared/extra-utils' @@ -250,7 +251,7 @@ export class VideoStreamingPlaylistModel extends Model>> { let files: VideoFile[] = [] if (Array.isArray(this.VideoFiles)) { - const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet) + const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet }) files = files.concat(result) } for (const p of (this.VideoStreamingPlaylists || [])) { - const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet) + const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }) files = files.concat(result) } @@ -1868,22 +1868,14 @@ export class VideoModel extends Model>> { return setAsUpdated('video', this.id, transaction) } - requiresAuth () { - return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist - } + requiresAuth (paramId: string) { + if (this.privacy === VideoPrivacy.UNLISTED) { + if (!isUUIDValid(paramId)) return true - setPrivacy (newPrivacy: VideoPrivacy) { - if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { - this.publishedAt = new Date() + return false } - this.privacy = newPrivacy - } - - isConfidential () { - return this.privacy === VideoPrivacy.PRIVATE || - this.privacy === VideoPrivacy.UNLISTED || - this.privacy === VideoPrivacy.INTERNAL + return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist } async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 33dc8fb76..961093bb5 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -34,6 +34,7 @@ import './video-imports' import './video-playlists' import './video-source' import './video-studio' +import './video-token' import './videos-common-filters' import './videos-history' import './videos-overviews' diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts index 3f553c42b..2eff9414b 100644 --- a/server/tests/api/check-params/live.ts +++ b/server/tests/api/check-params/live.ts @@ -502,6 +502,23 @@ describe('Test video lives API validator', function () { await stopFfmpeg(ffmpegCommand) }) + it('Should fail to change live privacy if it has already started', async function () { + this.timeout(40000) + + const live = await command.get({ videoId: video.id }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + + await command.waitUntilPublished({ videoId: video.id }) + await server.videos.update({ + id: video.id, + attributes: { privacy: VideoPrivacy.PUBLIC }, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await stopFfmpeg(ffmpegCommand) + }) + it('Should fail to stream twice in the save live', async function () { this.timeout(40000) diff --git a/server/tests/api/check-params/video-files.ts b/server/tests/api/check-params/video-files.ts index aa4de2c83..9dc59a1b5 100644 --- a/server/tests/api/check-params/video-files.ts +++ b/server/tests/api/check-params/video-files.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { HttpStatusCode, UserRole } from '@shared/models' +import { getAllFiles } from '@shared/core-utils' +import { HttpStatusCode, UserRole, VideoDetails, VideoPrivacy } from '@shared/models' import { cleanupTests, createMultipleServers, doubleFollow, + makeRawRequest, PeerTubeServer, setAccessTokensToServers, waitJobs @@ -13,22 +15,9 @@ import { describe('Test videos files', function () { let servers: PeerTubeServer[] - let webtorrentId: string - let hlsId: string - let remoteId: string - let userToken: string let moderatorToken: string - let validId1: string - let validId2: string - - let hlsFileId: number - let webtorrentFileId: number - - let remoteHLSFileId: number - let remoteWebtorrentFileId: number - // --------------------------------------------------------------- before(async function () { @@ -41,117 +30,163 @@ describe('Test videos files', function () { userToken = await servers[0].users.generateUserAndToken('user', UserRole.USER) moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) + }) - { - const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) - await waitJobs(servers) + describe('Getting metadata', function () { + let video: VideoDetails - const video = await servers[1].videos.get({ id: uuid }) - remoteId = video.uuid - remoteHLSFileId = video.streamingPlaylists[0].files[0].id - remoteWebtorrentFileId = video.files[0].id - } + before(async function () { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + video = await servers[0].videos.getWithToken({ id: uuid }) + }) - { - await servers[0].config.enableTranscoding(true, true) + it('Should not get metadata of private video without token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + } + }) + + it('Should not get metadata of private video without the appropriate token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) + + it('Should get metadata of private video with the appropriate token', async function () { + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.metadataUrl, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + }) + + describe('Deleting files', function () { + let webtorrentId: string + let hlsId: string + let remoteId: string + + let validId1: string + let validId2: string + + let hlsFileId: number + let webtorrentFileId: number + + let remoteHLSFileId: number + let remoteWebtorrentFileId: number + + before(async function () { + this.timeout(300_000) { - const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) + const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) await waitJobs(servers) - const video = await servers[0].videos.get({ id: uuid }) - validId1 = video.uuid - hlsFileId = video.streamingPlaylists[0].files[0].id - webtorrentFileId = video.files[0].id + const video = await servers[1].videos.get({ id: uuid }) + remoteId = video.uuid + remoteHLSFileId = video.streamingPlaylists[0].files[0].id + remoteWebtorrentFileId = video.files[0].id } { - const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) - validId2 = uuid + await servers[0].config.enableTranscoding(true, true) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + validId1 = video.uuid + hlsFileId = video.streamingPlaylists[0].files[0].id + webtorrentFileId = video.files[0].id + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) + validId2 = uuid + } } - } - await waitJobs(servers) + await waitJobs(servers) - { - await servers[0].config.enableTranscoding(false, true) - const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) - hlsId = uuid - } + { + await servers[0].config.enableTranscoding(false, true) + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) + hlsId = uuid + } - await waitJobs(servers) + await waitJobs(servers) - { - await servers[0].config.enableTranscoding(false, true) - const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) - webtorrentId = uuid - } + { + await servers[0].config.enableTranscoding(false, true) + const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) + webtorrentId = uuid + } - await waitJobs(servers) - }) + await waitJobs(servers) + }) - it('Should not delete files of a unknown video', async function () { - const expectedStatus = HttpStatusCode.NOT_FOUND_404 + it('Should not delete files of a unknown video', async function () { + const expectedStatus = HttpStatusCode.NOT_FOUND_404 - await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: 404, expectedStatus }) + await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: 404, expectedStatus }) - await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus }) - await servers[0].videos.removeWebTorrentFile({ videoId: 404, fileId: webtorrentFileId, expectedStatus }) - }) + await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus }) + await servers[0].videos.removeWebTorrentFile({ videoId: 404, fileId: webtorrentFileId, expectedStatus }) + }) - it('Should not delete unknown files', async function () { - const expectedStatus = HttpStatusCode.NOT_FOUND_404 + it('Should not delete unknown files', async function () { + const expectedStatus = HttpStatusCode.NOT_FOUND_404 - await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webtorrentFileId, expectedStatus }) - await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: hlsFileId, expectedStatus }) - }) + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webtorrentFileId, expectedStatus }) + await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: hlsFileId, expectedStatus }) + }) - it('Should not delete files of a remote video', async function () { - const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + it('Should not delete files of a remote video', async function () { + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 - await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: remoteId, expectedStatus }) + await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: remoteId, expectedStatus }) - await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus }) - await servers[0].videos.removeWebTorrentFile({ videoId: remoteId, fileId: remoteWebtorrentFileId, expectedStatus }) - }) + await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus }) + await servers[0].videos.removeWebTorrentFile({ videoId: remoteId, fileId: remoteWebtorrentFileId, expectedStatus }) + }) - it('Should not delete files by a non admin user', async function () { - const expectedStatus = HttpStatusCode.FORBIDDEN_403 + it('Should not delete files by a non admin user', async function () { + const expectedStatus = HttpStatusCode.FORBIDDEN_403 - await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus }) - await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus }) + await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) - await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus }) - await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus }) + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus }) + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus }) - await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: userToken, expectedStatus }) - await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: moderatorToken, expectedStatus }) - }) + await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: userToken, expectedStatus }) + await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: moderatorToken, expectedStatus }) + }) - it('Should not delete files if the files are not available', async function () { - await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + it('Should not delete files if the files are not available', async function () { + await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - }) + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) - it('Should not delete files if no both versions are available', async function () { - await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - }) + it('Should not delete files if no both versions are available', async function () { + await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) - it('Should delete files if both versions are available', async function () { - await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId }) - await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId }) + it('Should delete files if both versions are available', async function () { + await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId }) + await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId }) - await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) - await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId2 }) + await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) + await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId2 }) + }) }) after(async function () { diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts new file mode 100644 index 000000000..7acb9d580 --- /dev/null +++ b/server/tests/api/check-params/video-token.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoPrivacy } from '@shared/models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' + +describe('Test video tokens', function () { + let server: PeerTubeServer + let videoId: string + let userToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(300_000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + videoId = uuid + + userToken = await server.users.generateUserAndToken('user1') + }) + + it('Should not generate tokens for unauthenticated user', async function () { + await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not generate tokens of unknown video', async function () { + await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not generate tokens of a non owned video', async function () { + await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should generate token', async function () { + await server.videoToken.create({ videoId }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/live/live-fast-restream.ts b/server/tests/api/live/live-fast-restream.ts index 772ea792d..971df1a61 100644 --- a/server/tests/api/live/live-fast-restream.ts +++ b/server/tests/api/live/live-fast-restream.ts @@ -79,8 +79,8 @@ describe('Fast restream in live', function () { expect(video.streamingPlaylists).to.have.lengthOf(1) await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) - await makeRawRequest(video.streamingPlaylists[0].playlistUrl, HttpStatusCode.OK_200) - await makeRawRequest(video.streamingPlaylists[0].segmentsSha256Url, HttpStatusCode.OK_200) + await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) await wait(100) } diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index 3f2a304be..003cc934f 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -21,6 +21,7 @@ import { doubleFollow, killallServers, LiveCommand, + makeGetRequest, makeRawRequest, PeerTubeServer, sendRTMPStream, @@ -157,8 +158,8 @@ describe('Test live', function () { expect(video.privacy.id).to.equal(VideoPrivacy.UNLISTED) expect(video.nsfw).to.be.true - await makeRawRequest(server.url + video.thumbnailPath, HttpStatusCode.OK_200) - await makeRawRequest(server.url + video.previewPath, HttpStatusCode.OK_200) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) } }) @@ -532,8 +533,8 @@ describe('Test live', function () { expect(video.files).to.have.lengthOf(0) const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) - await makeRawRequest(hlsPlaylist.playlistUrl, HttpStatusCode.OK_200) - await makeRawRequest(hlsPlaylist.segmentsSha256Url, HttpStatusCode.OK_200) + await makeRawRequest({ url: hlsPlaylist.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) // We should have generated random filenames expect(basename(hlsPlaylist.playlistUrl)).to.not.equal('master.m3u8') @@ -564,8 +565,8 @@ describe('Test live', function () { expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) - await makeRawRequest(file.torrentUrl, HttpStatusCode.OK_200) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } } }) diff --git a/server/tests/api/object-storage/live.ts b/server/tests/api/object-storage/live.ts index 7e16b4c89..77f3a8066 100644 --- a/server/tests/api/object-storage/live.ts +++ b/server/tests/api/object-storage/live.ts @@ -48,7 +48,7 @@ async function checkFilesExist (servers: PeerTubeServer[], videoUUID: string, nu for (const file of files) { expectStartWith(file.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } } } diff --git a/server/tests/api/object-storage/video-imports.ts b/server/tests/api/object-storage/video-imports.ts index f688c7018..90988ea9a 100644 --- a/server/tests/api/object-storage/video-imports.ts +++ b/server/tests/api/object-storage/video-imports.ts @@ -66,7 +66,7 @@ describe('Object storage for video import', function () { const fileUrl = video.files[0].fileUrl expectStartWith(fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl()) - await makeRawRequest(fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.OK_200 }) }) }) @@ -91,13 +91,13 @@ describe('Object storage for video import', function () { for (const file of video.files) { expectStartWith(file.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } for (const file of video.streamingPlaylists[0].files) { expectStartWith(file.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } }) }) diff --git a/server/tests/api/object-storage/videos.ts b/server/tests/api/object-storage/videos.ts index 3e65e1093..63f5179c7 100644 --- a/server/tests/api/object-storage/videos.ts +++ b/server/tests/api/object-storage/videos.ts @@ -59,11 +59,11 @@ async function checkFiles (options: { expectStartWith(file.fileUrl, start) - const res = await makeRawRequest(file.fileDownloadUrl, HttpStatusCode.FOUND_302) + const res = await makeRawRequest({ url: file.fileDownloadUrl, expectedStatus: HttpStatusCode.FOUND_302 }) const location = res.headers['location'] expectStartWith(location, start) - await makeRawRequest(location, HttpStatusCode.OK_200) + await makeRawRequest({ url: location, expectedStatus: HttpStatusCode.OK_200 }) } const hls = video.streamingPlaylists[0] @@ -81,19 +81,19 @@ async function checkFiles (options: { expectStartWith(hls.playlistUrl, start) expectStartWith(hls.segmentsSha256Url, start) - await makeRawRequest(hls.playlistUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) - const resSha = await makeRawRequest(hls.segmentsSha256Url, HttpStatusCode.OK_200) + const resSha = await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) expect(JSON.stringify(resSha.body)).to.not.throw for (const file of hls.files) { expectStartWith(file.fileUrl, start) - const res = await makeRawRequest(file.fileDownloadUrl, HttpStatusCode.FOUND_302) + const res = await makeRawRequest({ url: file.fileDownloadUrl, expectedStatus: HttpStatusCode.FOUND_302 }) const location = res.headers['location'] expectStartWith(location, start) - await makeRawRequest(location, HttpStatusCode.OK_200) + await makeRawRequest({ url: location, expectedStatus: HttpStatusCode.OK_200 }) } } @@ -104,7 +104,7 @@ async function checkFiles (options: { expect(torrent.files.length).to.equal(1) expect(torrent.files[0].path).to.exist.and.to.not.equal('') - const res = await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + const res = await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.length.above(100) } @@ -220,7 +220,7 @@ function runTestSuite (options: { it('Should fetch correctly all the files', async function () { for (const url of deletedUrls.concat(keptUrls)) { - await makeRawRequest(url, HttpStatusCode.OK_200) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) } }) @@ -231,13 +231,13 @@ function runTestSuite (options: { await waitJobs(servers) for (const url of deletedUrls) { - await makeRawRequest(url, HttpStatusCode.NOT_FOUND_404) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } }) it('Should have kept other files', async function () { for (const url of keptUrls) { - await makeRawRequest(url, HttpStatusCode.OK_200) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) } }) diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts index f349a7a76..ba6b00e0b 100644 --- a/server/tests/api/redundancy/redundancy.ts +++ b/server/tests/api/redundancy/redundancy.ts @@ -39,7 +39,7 @@ async function checkMagnetWebseeds (file: VideoFile, baseWebseeds: string[], ser expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length) for (const url of parsed.urlList) { - await makeRawRequest(url, HttpStatusCode.OK_200) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) } } diff --git a/server/tests/api/server/open-telemetry.ts b/server/tests/api/server/open-telemetry.ts index 43a27cc32..7a294be82 100644 --- a/server/tests/api/server/open-telemetry.ts +++ b/server/tests/api/server/open-telemetry.ts @@ -18,7 +18,7 @@ describe('Open Telemetry', function () { let hasError = false try { - await makeRawRequest(metricsUrl, HttpStatusCode.NOT_FOUND_404) + await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } catch (err) { hasError = err.message.includes('ECONNREFUSED') } @@ -37,7 +37,7 @@ describe('Open Telemetry', function () { } }) - const res = await makeRawRequest(metricsUrl, HttpStatusCode.OK_200) + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.text).to.contain('peertube_job_queue_total{') }) @@ -60,7 +60,7 @@ describe('Open Telemetry', function () { } }) - const res = await makeRawRequest(metricsUrl, HttpStatusCode.OK_200) + const res = await makeRawRequest({ url: metricsUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(res.text).to.contain('peertube_playback_http_downloaded_bytes_total{') }) diff --git a/server/tests/api/transcoding/create-transcoding.ts b/server/tests/api/transcoding/create-transcoding.ts index a50bf7654..372f5332a 100644 --- a/server/tests/api/transcoding/create-transcoding.ts +++ b/server/tests/api/transcoding/create-transcoding.ts @@ -20,7 +20,7 @@ import { async function checkFilesInObjectStorage (video: VideoDetails) { for (const file of video.files) { expectStartWith(file.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } if (video.streamingPlaylists.length === 0) return @@ -28,14 +28,14 @@ async function checkFilesInObjectStorage (video: VideoDetails) { const hlsPlaylist = video.streamingPlaylists[0] for (const file of hlsPlaylist.files) { expectStartWith(file.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } expectStartWith(hlsPlaylist.playlistUrl, ObjectStorageCommand.getPlaylistBaseUrl()) - await makeRawRequest(hlsPlaylist.playlistUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: hlsPlaylist.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) expectStartWith(hlsPlaylist.segmentsSha256Url, ObjectStorageCommand.getPlaylistBaseUrl()) - await makeRawRequest(hlsPlaylist.segmentsSha256Url, HttpStatusCode.OK_200) + await makeRawRequest({ url: hlsPlaylist.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) } function runTests (objectStorage: boolean) { @@ -234,7 +234,7 @@ function runTests (objectStorage: boolean) { it('Should have correctly deleted previous files', async function () { for (const fileUrl of shouldBeDeleted) { - await makeRawRequest(fileUrl, HttpStatusCode.NOT_FOUND_404) + await makeRawRequest({ url: fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } }) diff --git a/server/tests/api/transcoding/hls.ts b/server/tests/api/transcoding/hls.ts index 252422e5d..7b5492cd4 100644 --- a/server/tests/api/transcoding/hls.ts +++ b/server/tests/api/transcoding/hls.ts @@ -1,168 +1,48 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' -import { basename, join } from 'path' -import { - checkDirectoryIsEmpty, - checkResolutionsInMasterPlaylist, - checkSegmentHash, - checkTmpIsEmpty, - expectStartWith, - hlsInfohashExist -} from '@server/tests/shared' -import { areObjectStorageTestsDisabled, removeFragmentedMP4Ext, uuidRegex } from '@shared/core-utils' -import { HttpStatusCode, VideoStreamingPlaylistType } from '@shared/models' +import { join } from 'path' +import { checkDirectoryIsEmpty, checkTmpIsEmpty, completeCheckHlsPlaylist } from '@server/tests/shared' +import { areObjectStorageTestsDisabled } from '@shared/core-utils' +import { HttpStatusCode } from '@shared/models' import { cleanupTests, createMultipleServers, doubleFollow, - makeRawRequest, ObjectStorageCommand, PeerTubeServer, setAccessTokensToServers, - waitJobs, - webtorrentAdd + waitJobs } from '@shared/server-commands' import { DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants' -async function checkHlsPlaylist (options: { - servers: PeerTubeServer[] - videoUUID: string - hlsOnly: boolean - - resolutions?: number[] - objectStorageBaseUrl: string -}) { - const { videoUUID, hlsOnly, objectStorageBaseUrl } = options - - const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ] - - for (const server of options.servers) { - const videoDetails = await server.videos.get({ id: videoUUID }) - const baseUrl = `http://${videoDetails.account.host}` - - expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) - - const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) - expect(hlsPlaylist).to.not.be.undefined - - const hlsFiles = hlsPlaylist.files - expect(hlsFiles).to.have.lengthOf(resolutions.length) - - if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0) - else expect(videoDetails.files).to.have.lengthOf(resolutions.length) - - // Check JSON files - for (const resolution of resolutions) { - const file = hlsFiles.find(f => f.resolution.id === resolution) - expect(file).to.not.be.undefined - - expect(file.magnetUri).to.have.lengthOf.above(2) - expect(file.torrentUrl).to.match( - new RegExp(`http://${server.host}/lazy-static/torrents/${uuidRegex}-${file.resolution.id}-hls.torrent`) - ) - - if (objectStorageBaseUrl) { - expectStartWith(file.fileUrl, objectStorageBaseUrl) - } else { - expect(file.fileUrl).to.match( - new RegExp(`${baseUrl}/static/streaming-playlists/hls/${videoDetails.uuid}/${uuidRegex}-${file.resolution.id}-fragmented.mp4`) - ) - } - - expect(file.resolution.label).to.equal(resolution + 'p') - - await makeRawRequest(file.torrentUrl, HttpStatusCode.OK_200) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) - - const torrent = await webtorrentAdd(file.magnetUri, true) - expect(torrent.files).to.be.an('array') - expect(torrent.files.length).to.equal(1) - expect(torrent.files[0].path).to.exist.and.to.not.equal('') - } - - // Check master playlist - { - await checkResolutionsInMasterPlaylist({ server, playlistUrl: hlsPlaylist.playlistUrl, resolutions }) - - const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl }) - - let i = 0 - for (const resolution of resolutions) { - expect(masterPlaylist).to.contain(`${resolution}.m3u8`) - expect(masterPlaylist).to.contain(`${resolution}.m3u8`) - - const url = 'http://' + videoDetails.account.host - await hlsInfohashExist(url, hlsPlaylist.playlistUrl, i) - - i++ - } - } - - // Check resolution playlists - { - for (const resolution of resolutions) { - const file = hlsFiles.find(f => f.resolution.id === resolution) - const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8' - - const url = objectStorageBaseUrl - ? `${objectStorageBaseUrl}hls/${videoUUID}/${playlistName}` - : `${baseUrl}/static/streaming-playlists/hls/${videoUUID}/${playlistName}` - - const subPlaylist = await server.streamingPlaylists.get({ url }) - - expect(subPlaylist).to.match(new RegExp(`${uuidRegex}-${resolution}-fragmented.mp4`)) - expect(subPlaylist).to.contain(basename(file.fileUrl)) - } - } - - { - const baseUrlAndPath = objectStorageBaseUrl - ? objectStorageBaseUrl + 'hls/' + videoUUID - : baseUrl + '/static/streaming-playlists/hls/' + videoUUID - - for (const resolution of resolutions) { - await checkSegmentHash({ - server, - baseUrlPlaylist: baseUrlAndPath, - baseUrlSegment: baseUrlAndPath, - resolution, - hlsPlaylist - }) - } - } - } -} - describe('Test HLS videos', function () { let servers: PeerTubeServer[] = [] - let videoUUID = '' - let videoAudioUUID = '' function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { + const videoUUIDs: string[] = [] it('Should upload a video and transcode it to HLS', async function () { this.timeout(120000) const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video 1', fixture: 'video_short.webm' } }) - videoUUID = uuid + videoUUIDs.push(uuid) await waitJobs(servers) - await checkHlsPlaylist({ servers, videoUUID, hlsOnly, objectStorageBaseUrl }) + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) }) it('Should upload an audio file and transcode it to HLS', async function () { this.timeout(120000) const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video audio', fixture: 'sample.ogg' } }) - videoAudioUUID = uuid + videoUUIDs.push(uuid) await waitJobs(servers) - await checkHlsPlaylist({ + await completeCheckHlsPlaylist({ servers, - videoUUID: videoAudioUUID, + videoUUID: uuid, hlsOnly, resolutions: [ DEFAULT_AUDIO_RESOLUTION, 360, 240 ], objectStorageBaseUrl @@ -172,31 +52,36 @@ describe('Test HLS videos', function () { it('Should update the video', async function () { this.timeout(30000) - await servers[0].videos.update({ id: videoUUID, attributes: { name: 'video 1 updated' } }) + await servers[0].videos.update({ id: videoUUIDs[0], attributes: { name: 'video 1 updated' } }) await waitJobs(servers) - await checkHlsPlaylist({ servers, videoUUID, hlsOnly, objectStorageBaseUrl }) + await completeCheckHlsPlaylist({ servers, videoUUID: videoUUIDs[0], hlsOnly, objectStorageBaseUrl }) }) it('Should delete videos', async function () { this.timeout(10000) - await servers[0].videos.remove({ id: videoUUID }) - await servers[0].videos.remove({ id: videoAudioUUID }) + for (const uuid of videoUUIDs) { + await servers[0].videos.remove({ id: uuid }) + } await waitJobs(servers) for (const server of servers) { - await server.videos.get({ id: videoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) - await server.videos.get({ id: videoAudioUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + for (const uuid of videoUUIDs) { + await server.videos.get({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } } }) it('Should have the playlists/segment deleted from the disk', async function () { for (const server of servers) { - await checkDirectoryIsEmpty(server, 'videos') - await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls')) + await checkDirectoryIsEmpty(server, 'videos', [ 'private' ]) + await checkDirectoryIsEmpty(server, join('videos', 'private')) + + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ]) + await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private')) } }) diff --git a/server/tests/api/transcoding/index.ts b/server/tests/api/transcoding/index.ts index 0cc28b4a4..9866418d6 100644 --- a/server/tests/api/transcoding/index.ts +++ b/server/tests/api/transcoding/index.ts @@ -2,4 +2,5 @@ export * from './audio-only' export * from './create-transcoding' export * from './hls' export * from './transcoder' +export * from './update-while-transcoding' export * from './video-studio' diff --git a/server/tests/api/transcoding/update-while-transcoding.ts b/server/tests/api/transcoding/update-while-transcoding.ts new file mode 100644 index 000000000..5ca923392 --- /dev/null +++ b/server/tests/api/transcoding/update-while-transcoding.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { completeCheckHlsPlaylist } from '@server/tests/shared' +import { areObjectStorageTestsDisabled, wait } from '@shared/core-utils' +import { VideoPrivacy } from '@shared/models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@shared/server-commands' + +describe('Test update video privacy while transcoding', function () { + let servers: PeerTubeServer[] = [] + + const videoUUIDs: string[] = [] + + function runTestSuite (hlsOnly: boolean, objectStorageBaseUrl?: string) { + + it('Should not have an error while quickly updating a private video to public after upload #1', async function () { + this.timeout(360_000) + + const attributes = { + name: 'quick update', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: false }) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + }) + + it('Should not have an error while quickly updating a private video to public after upload #2', async function () { + + { + const attributes = { + name: 'quick update 2', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: true }) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + } + }) + + it('Should not have an error while quickly updating a private video to public after upload #3', async function () { + const attributes = { + name: 'quick update 3', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes, waitTorrentGeneration: true }) + await wait(1000) + await servers[0].videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + videoUUIDs.push(uuid) + + await waitJobs(servers) + + await completeCheckHlsPlaylist({ servers, videoUUID: uuid, hlsOnly, objectStorageBaseUrl }) + }) + } + + before(async function () { + this.timeout(120000) + + const configOverride = { + transcoding: { + enabled: true, + allow_audio_files: true, + hls: { + enabled: true + } + } + } + servers = await createMultipleServers(2, configOverride) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('With WebTorrent & HLS enabled', function () { + runTestSuite(false) + }) + + describe('With only HLS enabled', function () { + + before(async function () { + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: true, + allowAudioFiles: true, + resolutions: { + '144p': false, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + hls: { + enabled: true + }, + webtorrent: { + enabled: false + } + } + } + }) + }) + + runTestSuite(true) + }) + + describe('With object storage enabled', function () { + if (areObjectStorageTestsDisabled()) return + + before(async function () { + this.timeout(120000) + + const configOverride = ObjectStorageCommand.getDefaultConfig() + await ObjectStorageCommand.prepareDefaultBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + runTestSuite(true, ObjectStorageCommand.getPlaylistBaseUrl()) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index 266155297..357c08199 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -19,3 +19,4 @@ import './videos-common-filters' import './videos-history' import './videos-overview' import './video-source' +import './video-static-file-privacy' diff --git a/server/tests/api/videos/video-files.ts b/server/tests/api/videos/video-files.ts index c0b886aad..8c913bf31 100644 --- a/server/tests/api/videos/video-files.ts +++ b/server/tests/api/videos/video-files.ts @@ -153,7 +153,7 @@ describe('Test videos files', function () { expect(video.streamingPlaylists[0].files).to.have.lengthOf(files.length - 1) expect(video.streamingPlaylists[0].files.find(f => f.id === toDelete.id)).to.not.exist - const { text } = await makeRawRequest(video.streamingPlaylists[0].playlistUrl) + const { text } = await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) expect(text.includes(`-${toDelete.resolution.id}.m3u8`)).to.be.false expect(text.includes(`-${video.streamingPlaylists[0].files[0].resolution.id}.m3u8`)).to.be.true diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts new file mode 100644 index 000000000..e38fdec6e --- /dev/null +++ b/server/tests/api/videos/video-static-file-privacy.ts @@ -0,0 +1,389 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { decode } from 'magnet-uri' +import { expectStartWith } from '@server/tests/shared' +import { getAllFiles, wait } from '@shared/core-utils' +import { HttpStatusCode, LiveVideo, VideoDetails, VideoPrivacy } from '@shared/models' +import { + cleanupTests, + createSingleServer, + findExternalSavedVideo, + makeRawRequest, + parseTorrentVideo, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@shared/server-commands' + +describe('Test video static file privacy', function () { + let server: PeerTubeServer + let userToken: string + + before(async function () { + this.timeout(50000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + }) + + describe('VOD static file path', function () { + + function runSuite () { + + async function checkPrivateWebTorrentFiles (uuid: string) { + const video = await server.videos.getWithToken({ id: uuid }) + + for (const file of video.files) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expectStartWith(file.fileUrl, server.url + '/static/webseed/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList).to.have.lengthOf(0) + + const magnet = decode(file.magnetUri) + expect(magnet.urlList).to.have.lengthOf(0) + + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expectStartWith(hls.playlistUrl, server.url + '/static/streaming-playlists/hls/private/') + expectStartWith(hls.segmentsSha256Url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url: hls.playlistUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + async function checkPublicWebTorrentFiles (uuid: string) { + const video = await server.videos.get({ id: uuid }) + + for (const file of getAllFiles(video)) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expect(file.fileUrl).to.not.include('/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList[0]).to.not.include('private') + + const magnet = decode(file.magnetUri) + expect(magnet.urlList[0]).to.not.include('private') + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: torrent.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: magnet.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expect(hls.playlistUrl).to.not.include('private') + expect(hls.segmentsSha256Url).to.not.include('private') + + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + it('Should upload a private/internal video and have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy }) + await waitJobs([ server ]) + + await checkPrivateWebTorrentFiles(uuid) + } + }) + + it('Should upload a public video and update it as private/internal to have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PUBLIC }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy } }) + await waitJobs([ server ]) + + await checkPrivateWebTorrentFiles(uuid) + } + }) + + it('Should upload a private video and update it to unlisted to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.UNLISTED } }) + await waitJobs([ server ]) + + await checkPublicWebTorrentFiles(uuid) + }) + + it('Should upload an internal video and update it to public to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs([ server ]) + + await checkPublicWebTorrentFiles(uuid) + }) + + it('Should upload an internal video and schedule a public publish', async function () { + this.timeout(120000) + + const attributes = { + name: 'video', + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: new Date(Date.now() + 1000).toISOString(), + privacy: VideoPrivacy.PUBLIC as VideoPrivacy.PUBLIC + } + } + + const { uuid } = await server.videos.upload({ attributes }) + + await waitJobs([ server ]) + await wait(1000) + await server.debug.sendCommand({ body: { command: 'process-update-videos-scheduler' } }) + + await waitJobs([ server ]) + + await checkPublicWebTorrentFiles(uuid) + }) + } + + describe('Without transcoding', function () { + runSuite() + }) + + describe('With transcoding', function () { + + before(async function () { + await server.config.enableMinimumTranscoding() + }) + + runSuite() + }) + }) + + describe('VOD static file right check', function () { + let unrelatedFileToken: string + + async function checkVideoFiles (options: { + id: string + expectedStatus: HttpStatusCode + token: string + videoFileToken: string + }) { + const { id, expectedStatus, token, videoFileToken } = options + + const video = await server.videos.getWithToken({ id }) + + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.fileUrl, token, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, token, expectedStatus }) + + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) + } + + const hls = video.streamingPlaylists[0] + await makeRawRequest({ url: hls.playlistUrl, token, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token, expectedStatus }) + + await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + }) + + it('Should not be able to access a private video files without OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) + }) + + it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken + }) + }) + + it('Should be able to access a private video files with appropriate OAuth token or 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 ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + + 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) + + const { uuid } = await server.videos.quickUpload({ name: 'video', token: userToken, privacy: VideoPrivacy.PRIVATE }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + }) + + describe('Live static file path and check', function () { + let normalLiveId: string + let normalLive: LiveVideo + + let permanentLiveId: string + let permanentLive: LiveVideo + + let unrelatedFileToken: string + + async function checkLiveFiles (live: LiveVideo, liveId: string) { + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.live.waitUntilPublished({ videoId: liveId }) + + const video = await server.videos.getWithToken({ id: liveId }) + const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + + const hls = video.streamingPlaylists[0] + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + + await stopFfmpeg(ffmpegCommand) + } + + async function checkReplay (replay: VideoDetails) { + const fileToken = await server.videoToken.getVideoFileToken({ videoId: replay.uuid }) + + const hls = replay.streamingPlaylists[0] + expect(hls.files).to.not.have.lengthOf(0) + + for (const file of hls.files) { + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url: file.fileUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: file.fileUrl, + query: { videoFileToken: unrelatedFileToken }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await server.config.enableLive({ + allowReplay: true, + transcoding: true, + resolutions: 'min' + }) + + { + const { video, live } = await server.live.quickCreate({ saveReplay: true, permanentLive: false, privacy: VideoPrivacy.PRIVATE }) + normalLiveId = video.uuid + normalLive = live + } + + { + const { video, live } = await server.live.quickCreate({ saveReplay: true, permanentLive: true, privacy: VideoPrivacy.PRIVATE }) + permanentLiveId = video.uuid + permanentLive = live + } + }) + + it('Should create a private normal live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(normalLive, normalLiveId) + }) + + it('Should create a private permanent live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(permanentLive, permanentLiveId) + }) + + it('Should have created a replay of the normal live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilReplacedByReplay({ videoId: normalLiveId }) + + const replay = await server.videos.getWithToken({ id: normalLiveId }) + await checkReplay(replay) + }) + + it('Should have created a replay of the permanent live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilWaiting({ videoId: permanentLiveId }) + await waitJobs([ server ]) + + const live = await server.videos.getWithToken({ id: permanentLiveId }) + const replayFromList = await findExternalSavedVideo(server, live) + const replay = await server.videos.getWithToken({ id: replayFromList.id }) + + await checkReplay(replay) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/cli/create-import-video-file-job.ts b/server/tests/cli/create-import-video-file-job.ts index 2cf2dd8f8..a4aa5f699 100644 --- a/server/tests/cli/create-import-video-file-job.ts +++ b/server/tests/cli/create-import-video-file-job.ts @@ -29,7 +29,7 @@ async function checkFiles (video: VideoDetails, objectStorage: boolean) { for (const file of video.files) { if (objectStorage) expectStartWith(file.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl()) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } } diff --git a/server/tests/cli/create-move-video-storage-job.ts b/server/tests/cli/create-move-video-storage-job.ts index 6a12a2c6c..ecdd75b76 100644 --- a/server/tests/cli/create-move-video-storage-job.ts +++ b/server/tests/cli/create-move-video-storage-job.ts @@ -22,7 +22,7 @@ async function checkFiles (origin: PeerTubeServer, video: VideoDetails, inObject expectStartWith(file.fileUrl, start) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } const start = inObjectStorage @@ -36,7 +36,7 @@ async function checkFiles (origin: PeerTubeServer, video: VideoDetails, inObject for (const file of hls.files) { expectStartWith(file.fileUrl, start) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } } diff --git a/server/tests/cli/create-transcoding-job.ts b/server/tests/cli/create-transcoding-job.ts index 8897d8c23..51bf04a80 100644 --- a/server/tests/cli/create-transcoding-job.ts +++ b/server/tests/cli/create-transcoding-job.ts @@ -23,7 +23,7 @@ async function checkFilesInObjectStorage (files: VideoFile[], type: 'webtorrent' expectStartWith(file.fileUrl, shouldStartWith) - await makeRawRequest(file.fileUrl, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) } } diff --git a/server/tests/cli/prune-storage.ts b/server/tests/cli/prune-storage.ts index a89e17e3c..ba0fa1f86 100644 --- a/server/tests/cli/prune-storage.ts +++ b/server/tests/cli/prune-storage.ts @@ -5,7 +5,7 @@ import { createFile, readdir } from 'fs-extra' import { join } from 'path' import { wait } from '@shared/core-utils' import { buildUUID } from '@shared/extra-utils' -import { HttpStatusCode, VideoPlaylistPrivacy } from '@shared/models' +import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' import { cleanupTests, CLICommand, @@ -36,22 +36,28 @@ async function assertNotExists (server: PeerTubeServer, directory: string, subst async function assertCountAreOkay (servers: PeerTubeServer[]) { for (const server of servers) { const videosCount = await countFiles(server, 'videos') - expect(videosCount).to.equal(8) + expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory + + const privateVideosCount = await countFiles(server, 'videos/private') + expect(privateVideosCount).to.equal(4) const torrentsCount = await countFiles(server, 'torrents') - expect(torrentsCount).to.equal(16) + expect(torrentsCount).to.equal(24) const previewsCount = await countFiles(server, 'previews') - expect(previewsCount).to.equal(2) + expect(previewsCount).to.equal(3) const thumbnailsCount = await countFiles(server, 'thumbnails') - expect(thumbnailsCount).to.equal(6) + expect(thumbnailsCount).to.equal(7) // 3 local videos, 1 local playlist, 2 remotes videos and 1 remote playlist const avatarsCount = await countFiles(server, 'avatars') expect(avatarsCount).to.equal(4) - const hlsRootCount = await countFiles(server, 'streaming-playlists/hls') - expect(hlsRootCount).to.equal(2) + const hlsRootCount = await countFiles(server, join('streaming-playlists', 'hls')) + expect(hlsRootCount).to.equal(3) // 2 videos + private directory + + const hlsPrivateRootCount = await countFiles(server, join('streaming-playlists', 'hls', 'private')) + expect(hlsPrivateRootCount).to.equal(1) } } @@ -67,8 +73,10 @@ describe('Test prune storage scripts', function () { await setDefaultVideoChannel(servers) for (const server of servers) { - await server.videos.upload({ attributes: { name: 'video 1' } }) - await server.videos.upload({ attributes: { name: 'video 2' } }) + await server.videos.upload({ attributes: { name: 'video 1', privacy: VideoPrivacy.PUBLIC } }) + await server.videos.upload({ attributes: { name: 'video 2', privacy: VideoPrivacy.PUBLIC } }) + + await server.videos.upload({ attributes: { name: 'video 3', privacy: VideoPrivacy.PRIVATE } }) await server.users.updateMyAvatar({ fixture: 'avatar.png' }) @@ -123,13 +131,16 @@ describe('Test prune storage scripts', function () { it('Should create some dirty files', async function () { for (let i = 0; i < 2; i++) { { - const base = servers[0].servers.buildDirectory('videos') + const basePublic = servers[0].servers.buildDirectory('videos') + const basePrivate = servers[0].servers.buildDirectory(join('videos', 'private')) const n1 = buildUUID() + '.mp4' const n2 = buildUUID() + '.webm' - await createFile(join(base, n1)) - await createFile(join(base, n2)) + await createFile(join(basePublic, n1)) + await createFile(join(basePublic, n2)) + await createFile(join(basePrivate, n1)) + await createFile(join(basePrivate, n2)) badNames['videos'] = [ n1, n2 ] } @@ -184,10 +195,12 @@ describe('Test prune storage scripts', function () { { const directory = join('streaming-playlists', 'hls') - const base = servers[0].servers.buildDirectory(directory) + const basePublic = servers[0].servers.buildDirectory(directory) + const basePrivate = servers[0].servers.buildDirectory(join(directory, 'private')) const n1 = buildUUID() - await createFile(join(base, n1)) + await createFile(join(basePublic, n1)) + await createFile(join(basePrivate, n1)) badNames[directory] = [ n1 ] } } diff --git a/server/tests/cli/regenerate-thumbnails.ts b/server/tests/cli/regenerate-thumbnails.ts index f459b11b8..16a8adcda 100644 --- a/server/tests/cli/regenerate-thumbnails.ts +++ b/server/tests/cli/regenerate-thumbnails.ts @@ -6,7 +6,7 @@ import { cleanupTests, createMultipleServers, doubleFollow, - makeRawRequest, + makeGetRequest, PeerTubeServer, setAccessTokensToServers, waitJobs @@ -16,8 +16,8 @@ async function testThumbnail (server: PeerTubeServer, videoId: number | string) const video = await server.videos.get({ id: videoId }) const requests = [ - makeRawRequest(join(server.url, video.thumbnailPath), HttpStatusCode.OK_200), - makeRawRequest(join(server.url, video.thumbnailPath), HttpStatusCode.OK_200) + makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }), + makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) ] for (const req of requests) { @@ -69,17 +69,17 @@ describe('Test regenerate thumbnails script', function () { it('Should have empty thumbnails', async function () { { - const res = await makeRawRequest(join(servers[0].url, video1.thumbnailPath), HttpStatusCode.OK_200) + const res = await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } { - const res = await makeRawRequest(join(servers[0].url, video2.thumbnailPath), HttpStatusCode.OK_200) + const res = await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.not.have.lengthOf(0) } { - const res = await makeRawRequest(join(servers[0].url, remoteVideo.thumbnailPath), HttpStatusCode.OK_200) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } }) @@ -94,21 +94,21 @@ describe('Test regenerate thumbnails script', function () { await testThumbnail(servers[0], video1.uuid) await testThumbnail(servers[0], video2.uuid) - const res = await makeRawRequest(join(servers[0].url, remoteVideo.thumbnailPath), HttpStatusCode.OK_200) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) }) it('Should have deleted old thumbnail files', async function () { { - await makeRawRequest(join(servers[0].url, video1.thumbnailPath), HttpStatusCode.NOT_FOUND_404) + await makeGetRequest({ url: servers[0].url, path: video1.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } { - await makeRawRequest(join(servers[0].url, video2.thumbnailPath), HttpStatusCode.NOT_FOUND_404) + await makeGetRequest({ url: servers[0].url, path: video2.thumbnailPath, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) } { - const res = await makeRawRequest(join(servers[0].url, remoteVideo.thumbnailPath), HttpStatusCode.OK_200) + const res = await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.body).to.have.lengthOf(0) } }) diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts index 0ddb641e6..c49175d5e 100644 --- a/server/tests/feeds/feeds.ts +++ b/server/tests/feeds/feeds.ts @@ -314,7 +314,7 @@ describe('Test syndication feeds', () => { const jsonObj = JSON.parse(json) const imageUrl = jsonObj.icon expect(imageUrl).to.include('/lazy-static/avatars/') - await makeRawRequest(imageUrl) + await makeRawRequest({ url: imageUrl }) }) }) diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts index ae4b3cf5f..083fd43ca 100644 --- a/server/tests/plugins/filter-hooks.ts +++ b/server/tests/plugins/filter-hooks.ts @@ -6,6 +6,7 @@ import { cleanupTests, createMultipleServers, doubleFollow, + makeGetRequest, makeRawRequest, PeerTubeServer, PluginsCommand, @@ -461,30 +462,41 @@ describe('Test plugin filter hooks', function () { }) it('Should run filter:api.download.torrent.allowed.result', async function () { - const res = await makeRawRequest(downloadVideos[0].files[0].torrentDownloadUrl, 403) + const res = await makeRawRequest({ url: downloadVideos[0].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_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) + await makeRawRequest({ url: downloadVideos[1].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: downloadVideos[2].files[0].torrentDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) }) it('Should run filter:api.download.video.allowed.result', async function () { { - const res = await makeRawRequest(downloadVideos[1].files[0].fileDownloadUrl, 403) + const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_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) + await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) } { - const res = await makeRawRequest(downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl, 403) + const res = await makeRawRequest({ + url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + expect(res.body.error).to.equal('Sun Jian') - await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200) + await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) - await makeRawRequest(downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 200) - await makeRawRequest(downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 200) + await makeRawRequest({ + url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeRawRequest({ + url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, + expectedStatus: HttpStatusCode.OK_200 + }) } }) }) @@ -515,12 +527,12 @@ describe('Test plugin filter hooks', function () { }) it('Should run filter:html.embed.video.allowed.result', async function () { - const res = await makeRawRequest(servers[0].url + embedVideos[0].embedPath, 200) + const res = await makeGetRequest({ url: servers[0].url, path: embedVideos[0].embedPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.text).to.equal('Lu Bu') }) it('Should run filter:html.embed.video-playlist.allowed.result', async function () { - const res = await makeRawRequest(servers[0].url + embedPlaylists[0].embedPath, 200) + const res = await makeGetRequest({ url: servers[0].url, path: embedPlaylists[0].embedPath, expectedStatus: HttpStatusCode.OK_200 }) expect(res.text).to.equal('Diao Chan') }) }) diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts index 31c18350a..f2bada4ee 100644 --- a/server/tests/plugins/plugin-helpers.ts +++ b/server/tests/plugins/plugin-helpers.ts @@ -307,7 +307,7 @@ describe('Test plugin helpers', function () { expect(file.fps).to.equal(25) expect(await pathExists(file.path)).to.be.true - await makeRawRequest(file.url, HttpStatusCode.OK_200) + await makeRawRequest({ url: file.url, expectedStatus: HttpStatusCode.OK_200 }) } } @@ -321,12 +321,12 @@ describe('Test plugin helpers', function () { const miniature = body.thumbnails.find(t => t.type === ThumbnailType.MINIATURE) expect(miniature).to.exist expect(await pathExists(miniature.path)).to.be.true - await makeRawRequest(miniature.url, HttpStatusCode.OK_200) + await makeRawRequest({ url: miniature.url, expectedStatus: HttpStatusCode.OK_200 }) const preview = body.thumbnails.find(t => t.type === ThumbnailType.PREVIEW) expect(preview).to.exist expect(await pathExists(preview.path)).to.be.true - await makeRawRequest(preview.url, HttpStatusCode.OK_200) + await makeRawRequest({ url: preview.url, expectedStatus: HttpStatusCode.OK_200 }) } }) diff --git a/server/tests/shared/streaming-playlists.ts b/server/tests/shared/streaming-playlists.ts index 74c25e99c..8ee04d921 100644 --- a/server/tests/shared/streaming-playlists.ts +++ b/server/tests/shared/streaming-playlists.ts @@ -1,9 +1,13 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + import { expect } from 'chai' import { basename } from 'path' -import { removeFragmentedMP4Ext } from '@shared/core-utils' +import { removeFragmentedMP4Ext, uuidRegex } from '@shared/core-utils' import { sha256 } from '@shared/extra-utils' -import { HttpStatusCode, VideoStreamingPlaylist } from '@shared/models' -import { PeerTubeServer } from '@shared/server-commands' +import { HttpStatusCode, VideoStreamingPlaylist, VideoStreamingPlaylistType } from '@shared/models' +import { makeRawRequest, PeerTubeServer, webtorrentAdd } from '@shared/server-commands' +import { expectStartWith } from './checks' +import { hlsInfohashExist } from './tracker' async function checkSegmentHash (options: { server: PeerTubeServer @@ -75,8 +79,118 @@ async function checkResolutionsInMasterPlaylist (options: { expect(playlistsLength).to.have.lengthOf(resolutions.length) } +async function completeCheckHlsPlaylist (options: { + servers: PeerTubeServer[] + videoUUID: string + hlsOnly: boolean + + resolutions?: number[] + objectStorageBaseUrl: string +}) { + const { videoUUID, hlsOnly, objectStorageBaseUrl } = options + + const resolutions = options.resolutions ?? [ 240, 360, 480, 720 ] + + for (const server of options.servers) { + const videoDetails = await server.videos.get({ id: videoUUID }) + const baseUrl = `http://${videoDetails.account.host}` + + expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) + + const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) + expect(hlsPlaylist).to.not.be.undefined + + const hlsFiles = hlsPlaylist.files + expect(hlsFiles).to.have.lengthOf(resolutions.length) + + if (hlsOnly) expect(videoDetails.files).to.have.lengthOf(0) + else expect(videoDetails.files).to.have.lengthOf(resolutions.length) + + // Check JSON files + for (const resolution of resolutions) { + const file = hlsFiles.find(f => f.resolution.id === resolution) + expect(file).to.not.be.undefined + + expect(file.magnetUri).to.have.lengthOf.above(2) + expect(file.torrentUrl).to.match( + new RegExp(`http://${server.host}/lazy-static/torrents/${uuidRegex}-${file.resolution.id}-hls.torrent`) + ) + + if (objectStorageBaseUrl) { + expectStartWith(file.fileUrl, objectStorageBaseUrl) + } else { + expect(file.fileUrl).to.match( + new RegExp(`${baseUrl}/static/streaming-playlists/hls/${videoDetails.uuid}/${uuidRegex}-${file.resolution.id}-fragmented.mp4`) + ) + } + + expect(file.resolution.label).to.equal(resolution + 'p') + + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + + const torrent = await webtorrentAdd(file.magnetUri, true) + expect(torrent.files).to.be.an('array') + expect(torrent.files.length).to.equal(1) + expect(torrent.files[0].path).to.exist.and.to.not.equal('') + } + + // Check master playlist + { + await checkResolutionsInMasterPlaylist({ server, playlistUrl: hlsPlaylist.playlistUrl, resolutions }) + + const masterPlaylist = await server.streamingPlaylists.get({ url: hlsPlaylist.playlistUrl }) + + let i = 0 + for (const resolution of resolutions) { + expect(masterPlaylist).to.contain(`${resolution}.m3u8`) + expect(masterPlaylist).to.contain(`${resolution}.m3u8`) + + const url = 'http://' + videoDetails.account.host + await hlsInfohashExist(url, hlsPlaylist.playlistUrl, i) + + i++ + } + } + + // Check resolution playlists + { + for (const resolution of resolutions) { + const file = hlsFiles.find(f => f.resolution.id === resolution) + const playlistName = removeFragmentedMP4Ext(basename(file.fileUrl)) + '.m3u8' + + const url = objectStorageBaseUrl + ? `${objectStorageBaseUrl}hls/${videoUUID}/${playlistName}` + : `${baseUrl}/static/streaming-playlists/hls/${videoUUID}/${playlistName}` + + const subPlaylist = await server.streamingPlaylists.get({ url }) + + expect(subPlaylist).to.match(new RegExp(`${uuidRegex}-${resolution}-fragmented.mp4`)) + expect(subPlaylist).to.contain(basename(file.fileUrl)) + } + } + + { + const baseUrlAndPath = objectStorageBaseUrl + ? objectStorageBaseUrl + 'hls/' + videoUUID + : baseUrl + '/static/streaming-playlists/hls/' + videoUUID + + for (const resolution of resolutions) { + await checkSegmentHash({ + server, + baseUrlPlaylist: baseUrlAndPath, + baseUrlSegment: baseUrlAndPath, + resolution, + hlsPlaylist + }) + } + } + } +} + export { checkSegmentHash, checkLiveSegmentHash, - checkResolutionsInMasterPlaylist + checkResolutionsInMasterPlaylist, + completeCheckHlsPlaylist } diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts index e18329e07..c8339584b 100644 --- a/server/tests/shared/videos.ts +++ b/server/tests/shared/videos.ts @@ -125,9 +125,9 @@ async function completeVideoCheck ( expect(file.fileDownloadUrl).to.match(new RegExp(`http://${originHost}/download/videos/${uuidRegex}-${file.resolution.id}${extension}`)) await Promise.all([ - makeRawRequest(file.torrentUrl, 200), - makeRawRequest(file.torrentDownloadUrl, 200), - makeRawRequest(file.metadataUrl, 200) + makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.torrentDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }), + makeRawRequest({ url: file.metadataUrl, expectedStatus: HttpStatusCode.OK_200 }) ]) expect(file.resolution.id).to.equal(attributeFile.resolution) diff --git a/shared/core-utils/common/url.ts b/shared/core-utils/common/url.ts index fd54e7594..d1c399f7b 100644 --- a/shared/core-utils/common/url.ts +++ b/shared/core-utils/common/url.ts @@ -1,6 +1,16 @@ import { Video, VideoPlaylist } from '../../models' import { secondsToTime } from './date' +function addQueryParams (url: string, params: { [ id: string ]: string }) { + const objUrl = new URL(url) + + for (const key of Object.keys(params)) { + objUrl.searchParams.append(key, params[key]) + } + + return objUrl.toString() +} + function buildPlaylistLink (playlist: Pick, base?: string) { return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist) } @@ -103,6 +113,8 @@ function decoratePlaylistLink (options: { // --------------------------------------------------------------------------- export { + addQueryParams, + buildPlaylistLink, buildVideoLink, diff --git a/shared/models/server/debug.model.ts b/shared/models/server/debug.model.ts index 1c4597b8b..41f2109af 100644 --- a/shared/models/server/debug.model.ts +++ b/shared/models/server/debug.model.ts @@ -8,4 +8,5 @@ export interface SendDebugCommand { | 'process-video-views-buffer' | 'process-video-viewers' | 'process-video-channel-sync-latest' + | 'process-update-videos-scheduler' } diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index f8e6976d3..4c1790228 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -33,6 +33,8 @@ export * from './video-storage.enum' export * from './video-streaming-playlist.model' export * from './video-streaming-playlist.type' +export * from './video-token.model' + export * from './video-update.model' export * from './video-view.model' export * from './video.model' diff --git a/shared/models/videos/video-token.model.ts b/shared/models/videos/video-token.model.ts new file mode 100644 index 000000000..aefea565f --- /dev/null +++ b/shared/models/videos/video-token.model.ts @@ -0,0 +1,6 @@ +export interface VideoToken { + files: { + token: string + expires: string | Date + } +} diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts index 8cc1245e0..b247017fd 100644 --- a/shared/server-commands/requests/requests.ts +++ b/shared/server-commands/requests/requests.ts @@ -3,7 +3,7 @@ import { decode } from 'querystring' import request from 'supertest' import { URL } from 'url' -import { buildAbsoluteFixturePath } from '@shared/core-utils' +import { buildAbsoluteFixturePath, pick } from '@shared/core-utils' import { HttpStatusCode } from '@shared/models' export type CommonRequestParams = { @@ -21,10 +21,21 @@ export type CommonRequestParams = { expectedStatus?: HttpStatusCode } -function makeRawRequest (url: string, expectedStatus?: HttpStatusCode, range?: string) { - const { host, protocol, pathname } = new URL(url) +function makeRawRequest (options: { + url: string + token?: string + expectedStatus?: HttpStatusCode + range?: string + query?: { [ id: string ]: string } +}) { + const { host, protocol, pathname } = new URL(options.url) - return makeGetRequest({ url: `${protocol}//${host}`, path: pathname, expectedStatus, range }) + return makeGetRequest({ + url: `${protocol}//${host}`, + path: pathname, + + ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ]) + }) } function makeGetRequest (options: CommonRequestParams & { diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index 7096faf21..c062e6986 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -36,6 +36,7 @@ import { StreamingPlaylistsCommand, VideosCommand, VideoStudioCommand, + VideoTokenCommand, ViewsCommand } from '../videos' import { CommentsCommand } from '../videos/comments-command' @@ -145,6 +146,7 @@ export class PeerTubeServer { videoStats?: VideoStatsCommand views?: ViewsCommand twoFactor?: TwoFactorCommand + videoToken?: VideoTokenCommand constructor (options: { serverNumber: number } | { url: string }) { if ((options as any).url) { @@ -427,5 +429,6 @@ export class PeerTubeServer { this.videoStats = new VideoStatsCommand(this) this.views = new ViewsCommand(this) this.twoFactor = new TwoFactorCommand(this) + this.videoToken = new VideoTokenCommand(this) } } diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index b4d6fa37b..c17f6ef20 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts @@ -14,5 +14,6 @@ export * from './services-command' export * from './streaming-playlists-command' export * from './comments-command' export * from './video-studio-command' +export * from './video-token-command' export * from './views-command' export * from './videos-command' diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts index b163f7189..de193fa49 100644 --- a/shared/server-commands/videos/live-command.ts +++ b/shared/server-commands/videos/live-command.ts @@ -12,6 +12,7 @@ import { ResultList, VideoCreateResult, VideoDetails, + VideoPrivacy, VideoState } from '@shared/models' import { unwrapBody } from '../requests' @@ -115,6 +116,31 @@ export class LiveCommand extends AbstractCommand { return body.video } + async quickCreate (options: OverrideCommandOptions & { + saveReplay: boolean + permanentLive: boolean + privacy?: VideoPrivacy + }) { + const { saveReplay, permanentLive, privacy } = options + + const { uuid } = await this.create({ + ...options, + + fields: { + name: 'live', + permanentLive, + saveReplay, + channelId: this.server.store.channel.id, + privacy + } + }) + + const video = await this.server.videos.getWithToken({ id: uuid }) + const live = await this.get({ videoId: uuid }) + + return { video, live } + } + // --------------------------------------------------------------------------- async sendRTMPStreamInVideo (options: OverrideCommandOptions & { diff --git a/shared/server-commands/videos/live.ts b/shared/server-commands/videos/live.ts index 0d9c32aab..ee7444b64 100644 --- a/shared/server-commands/videos/live.ts +++ b/shared/server-commands/videos/live.ts @@ -1,6 +1,6 @@ import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' import { buildAbsoluteFixturePath, wait } from '@shared/core-utils' -import { VideoDetails, VideoInclude } from '@shared/models' +import { VideoDetails, VideoInclude, VideoPrivacy } from '@shared/models' import { PeerTubeServer } from '../server/server' function sendRTMPStream (options: { @@ -98,7 +98,10 @@ async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServe } async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) { - const { data } = await server.videos.list({ token: server.accessToken, sort: '-publishedAt', include: VideoInclude.BLACKLISTED }) + const include = VideoInclude.BLACKLISTED + const privacyOneOf = [ VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.PUBLIC, VideoPrivacy.UNLISTED ] + + const { data } = await server.videos.list({ token: server.accessToken, sort: '-publishedAt', include, privacyOneOf }) return data.find(v => v.name === liveDetails.name + ' - ' + new Date(liveDetails.publishedAt).toLocaleString()) } diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts new file mode 100644 index 000000000..0531bee65 --- /dev/null +++ b/shared/server-commands/videos/video-token-command.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ + +import { HttpStatusCode, VideoToken } from '@shared/models' +import { unwrapBody } from '../requests' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class VideoTokenCommand extends AbstractCommand { + + create (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { videoId } = options + const path = '/api/v1/videos/' + videoId + '/token' + + return unwrapBody(this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + } + + async getVideoFileToken (options: OverrideCommandOptions & { + videoId: number | string + }) { + const { files } = await this.create(options) + + return files.token + } +} diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts index 168391523..5ec3b6ba8 100644 --- a/shared/server-commands/videos/videos-command.ts +++ b/shared/server-commands/videos/videos-command.ts @@ -342,8 +342,9 @@ export class VideosCommand extends AbstractCommand { async upload (options: OverrideCommandOptions & { attributes?: VideoEdit mode?: 'legacy' | 'resumable' // default legacy + waitTorrentGeneration?: boolean // default true } = {}) { - const { mode = 'legacy' } = options + const { mode = 'legacy', waitTorrentGeneration } = options let defaultChannelId = 1 try { @@ -377,7 +378,7 @@ export class VideosCommand extends AbstractCommand { // Wait torrent generation const expectedStatus = this.buildExpectedStatus({ ...options, defaultExpectedStatus: HttpStatusCode.OK_200 }) - if (expectedStatus === HttpStatusCode.OK_200) { + if (expectedStatus === HttpStatusCode.OK_200 && waitTorrentGeneration) { let video: VideoDetails do { @@ -692,6 +693,7 @@ export class VideosCommand extends AbstractCommand { 'categoryOneOf', 'licenceOneOf', 'languageOneOf', + 'privacyOneOf', 'tagsOneOf', 'tagsAllOf', 'isLocal', diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 2fb154dbd..7ffe8c67b 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -276,8 +276,8 @@ tags: description: Video transcoding related operations - name: Video stats description: Video statistics - - name: Feeds - description: Server syndication feeds + - name: Video Feeds + description: Server syndication feeds of videos - name: Search description: | The search helps to find _videos_ or _channels_ from within the instance and beyond. @@ -299,6 +299,12 @@ tags: Statistics x-tagGroups: + - name: Static endpoints + tags: + - Static Video Files + - name: Feeds + tags: + - Video Feeds - name: Auth tags: - Register @@ -327,7 +333,6 @@ x-tagGroups: - Video Files - Video Transcoding - Live Videos - - Feeds - Channels Sync - name: Search tags: @@ -349,7 +354,326 @@ x-tagGroups: - Logs - Job paths: - '/accounts/{name}': + '/static/webseed/{filename}': + get: + tags: + - Static Video Files + summary: Get public WebTorrent video file + parameters: + - $ref: '#/components/parameters/staticFilename' + responses: + '200': + description: successful operation + '404': + description: not found + '/static/webseed/private/{filename}': + get: + tags: + - Static Video Files + summary: Get private WebTorrent video file + parameters: + - $ref: '#/components/parameters/staticFilename' + - $ref: '#/components/parameters/videoFileToken' + security: + - OAuth2: [] + responses: + '200': + description: successful operation + '403': + description: invalid auth + '404': + description: not found + + '/static/streaming-playlists/hls/{filename}': + get: + tags: + - Static Video Files + summary: Get public HLS video file + parameters: + - $ref: '#/components/parameters/staticFilename' + security: + - OAuth2: [] + responses: + '200': + description: successful operation + '403': + description: invalid auth + '404': + description: not found + '/static/streaming-playlists/hls/private/{filename}': + get: + tags: + - Static Video Files + summary: Get private HLS video file + parameters: + - $ref: '#/components/parameters/staticFilename' + - $ref: '#/components/parameters/videoFileToken' + security: + - OAuth2: [] + responses: + '200': + description: successful operation + '403': + description: invalid auth + '404': + description: not found + + + '/feeds/video-comments.{format}': + get: + tags: + - Video Feeds + summary: List comments on videos + operationId: getSyndicatedComments + parameters: + - name: format + in: path + required: true + description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' + schema: + type: string + enum: + - xml + - rss + - rss2 + - atom + - atom1 + - json + - json1 + - name: videoId + in: query + description: 'limit listing to a specific video' + schema: + type: string + - name: accountId + in: query + description: 'limit listing to a specific account' + schema: + type: string + - name: accountName + in: query + description: 'limit listing to a specific account' + schema: + type: string + - name: videoChannelId + in: query + description: 'limit listing to a specific video channel' + schema: + type: string + - name: videoChannelName + in: query + description: 'limit listing to a specific video channel' + schema: + type: string + responses: + '204': + description: successful operation + headers: + Cache-Control: + schema: + type: string + default: 'max-age=900' # 15 min cache + content: + application/xml: + schema: + $ref: '#/components/schemas/VideoCommentsForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/video-comments.xml?filter=local + application/rss+xml: + schema: + $ref: '#/components/schemas/VideoCommentsForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/video-comments.rss?filter=local + text/xml: + schema: + $ref: '#/components/schemas/VideoCommentsForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/video-comments.xml?filter=local + application/atom+xml: + schema: + $ref: '#/components/schemas/VideoCommentsForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/video-comments.atom?filter=local + application/json: + schema: + type: object + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/video-comments.json?filter=local + '400': + x-summary: field inconsistencies + description: > + Arises when: + - videoId filter is mixed with a channel filter + '404': + description: video, video channel or account not found + '406': + description: accept header unsupported + + '/feeds/videos.{format}': + get: + tags: + - Video Feeds + summary: List videos + operationId: getSyndicatedVideos + parameters: + - name: format + in: path + required: true + description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' + schema: + type: string + enum: + - xml + - rss + - rss2 + - atom + - atom1 + - json + - json1 + - name: accountId + in: query + description: 'limit listing to a specific account' + schema: + type: string + - name: accountName + in: query + description: 'limit listing to a specific account' + schema: + type: string + - name: videoChannelId + in: query + description: 'limit listing to a specific video channel' + schema: + type: string + - name: videoChannelName + in: query + description: 'limit listing to a specific video channel' + schema: + type: string + - $ref: '#/components/parameters/sort' + - $ref: '#/components/parameters/nsfw' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' + - $ref: '#/components/parameters/hasHLSFiles' + - $ref: '#/components/parameters/hasWebtorrentFiles' + responses: + '204': + description: successful operation + headers: + Cache-Control: + schema: + type: string + default: 'max-age=900' # 15 min cache + content: + application/xml: + schema: + $ref: '#/components/schemas/VideosForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/videos.xml?filter=local + application/rss+xml: + schema: + $ref: '#/components/schemas/VideosForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/videos.rss?filter=local + text/xml: + schema: + $ref: '#/components/schemas/VideosForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/videos.xml?filter=local + application/atom+xml: + schema: + $ref: '#/components/schemas/VideosForXML' + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/videos.atom?filter=local + application/json: + schema: + type: object + examples: + nightly: + externalValue: https://peertube2.cpy.re/feeds/videos.json?filter=local + '404': + description: video channel or account not found + '406': + description: accept header unsupported + + '/feeds/subscriptions.{format}': + get: + tags: + - Video Feeds + summary: List videos of subscriptions tied to a token + operationId: getSyndicatedSubscriptionVideos + parameters: + - name: format + in: path + required: true + description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' + schema: + type: string + enum: + - xml + - rss + - rss2 + - atom + - atom1 + - json + - json1 + - name: accountId + in: query + description: limit listing to a specific account + schema: + type: string + required: true + - name: token + in: query + description: private token allowing access + schema: + type: string + required: true + - $ref: '#/components/parameters/sort' + - $ref: '#/components/parameters/nsfw' + - $ref: '#/components/parameters/isLocal' + - $ref: '#/components/parameters/include' + - $ref: '#/components/parameters/privacyOneOf' + - $ref: '#/components/parameters/hasHLSFiles' + - $ref: '#/components/parameters/hasWebtorrentFiles' + responses: + '204': + description: successful operation + headers: + Cache-Control: + schema: + type: string + default: 'max-age=900' # 15 min cache + content: + application/xml: + schema: + $ref: '#/components/schemas/VideosForXML' + application/rss+xml: + schema: + $ref: '#/components/schemas/VideosForXML' + text/xml: + schema: + $ref: '#/components/schemas/VideosForXML' + application/atom+xml: + schema: + $ref: '#/components/schemas/VideosForXML' + application/json: + schema: + type: object + '406': + description: accept header unsupported + + '/api/v1/accounts/{name}': get: tags: - Accounts @@ -367,7 +691,7 @@ paths: '404': description: account not found - '/accounts/{name}/videos': + '/api/v1/accounts/{name}/videos': get: tags: - Accounts @@ -434,7 +758,7 @@ paths: print(json) - '/accounts/{name}/followers': + '/api/v1/accounts/{name}/followers': get: tags: - Accounts @@ -464,7 +788,7 @@ paths: items: $ref: '#/components/schemas/Follow' - /accounts: + /api/v1/accounts: get: tags: - Accounts @@ -484,7 +808,7 @@ paths: items: $ref: '#/components/schemas/Account' - /config: + /api/v1/config: get: tags: - Config @@ -501,7 +825,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/config - /config/about: + /api/v1/config/about: get: summary: Get instance "About" information operationId: getAbout @@ -518,7 +842,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/config/about - /config/custom: + /api/v1/config/custom: get: summary: Get instance runtime configuration operationId: getCustomConfig @@ -563,7 +887,7 @@ paths: '200': description: successful operation - /custom-pages/homepage/instance: + /api/v1/custom-pages/homepage/instance: get: summary: Get instance custom homepage tags: @@ -597,7 +921,7 @@ paths: '204': description: successful operation - /jobs/pause: + /api/v1/jobs/pause: post: summary: Pause job queue security: @@ -609,7 +933,7 @@ paths: '204': description: successful operation - /jobs/resume: + /api/v1/jobs/resume: post: summary: Resume job queue security: @@ -621,7 +945,7 @@ paths: '204': description: successful operation - /jobs/{state}: + /api/v1/jobs/{state}: get: summary: List instance jobs operationId: getJobs @@ -665,7 +989,7 @@ paths: items: $ref: '#/components/schemas/Job' - /server/followers: + /api/v1/server/followers: get: tags: - Instance Follows @@ -692,7 +1016,7 @@ paths: items: $ref: '#/components/schemas/Follow' - '/server/followers/{nameWithHost}': + '/api/v1/server/followers/{nameWithHost}': delete: summary: Remove or reject a follower to your server security: @@ -714,7 +1038,7 @@ paths: '404': description: follower not found - '/server/followers/{nameWithHost}/reject': + '/api/v1/server/followers/{nameWithHost}/reject': post: summary: Reject a pending follower to your server security: @@ -736,7 +1060,7 @@ paths: '404': description: follower not found - '/server/followers/{nameWithHost}/accept': + '/api/v1/server/followers/{nameWithHost}/accept': post: summary: Accept a pending follower to your server security: @@ -758,7 +1082,7 @@ paths: '404': description: follower not found - /server/following: + /api/v1/server/following: get: tags: - Instance Follows @@ -814,7 +1138,7 @@ paths: type: string uniqueItems: true - '/server/following/{hostOrHandle}': + '/api/v1/server/following/{hostOrHandle}': delete: summary: Unfollow an actor (PeerTube instance, channel or account) security: @@ -835,7 +1159,7 @@ paths: '404': description: host or handle not found - /users: + /api/v1/users: post: summary: Create a user operationId: addUser @@ -902,7 +1226,7 @@ paths: items: $ref: '#/components/schemas/User' - '/users/{id}': + '/api/v1/users/{id}': parameters: - $ref: '#/components/parameters/id' delete: @@ -958,7 +1282,7 @@ paths: $ref: '#/components/schemas/UpdateUser' required: true - /oauth-clients/local: + /api/v1/oauth-clients/local: get: summary: Login prerequisite description: You need to retrieve a client id and secret before [logging in](#operation/getOAuthToken). @@ -986,7 +1310,7 @@ paths: ## AUTH curl -s "$API/oauth-clients/local" - /users/token: + /api/v1/users/token: post: summary: Login operationId: getOAuthToken @@ -1063,7 +1387,7 @@ paths: --data password="$PASSWORD" \ | jq -r ".access_token" - /users/revoke-token: + /api/v1/users/revoke-token: post: summary: Logout description: Revokes your access token and its associated refresh token, destroying your current session. @@ -1076,7 +1400,7 @@ paths: '200': description: successful operation - /users/register: + /api/v1/users/register: post: summary: Register a user operationId: registerUser @@ -1093,7 +1417,7 @@ paths: $ref: '#/components/schemas/RegisterUser' required: true - /users/{id}/verify-email: + /api/v1/users/{id}/verify-email: post: summary: Verify a user operationId: verifyUser @@ -1126,7 +1450,7 @@ paths: '404': description: user not found - /users/{id}/two-factor/request: + /api/v1/users/{id}/two-factor/request: post: summary: Request two factor auth operationId: requestTwoFactor @@ -1158,7 +1482,7 @@ paths: '404': description: user not found - /users/{id}/two-factor/confirm-request: + /api/v1/users/{id}/two-factor/confirm-request: post: summary: Confirm two factor auth operationId: confirmTwoFactorRequest @@ -1190,7 +1514,7 @@ paths: '404': description: user not found - /users/{id}/two-factor/disable: + /api/v1/users/{id}/two-factor/disable: post: summary: Disable two factor auth operationId: disableTwoFactor @@ -1217,7 +1541,7 @@ paths: description: user not found - /users/ask-send-verify-email: + /api/v1/users/ask-send-verify-email: post: summary: Resend user verification link operationId: resendEmailToVerifyUser @@ -1228,7 +1552,7 @@ paths: '204': description: successful operation - /users/me: + /api/v1/users/me: get: summary: Get my user information operationId: getUserInfo @@ -1264,7 +1588,7 @@ paths: $ref: '#/components/schemas/UpdateMe' required: true - /users/me/videos/imports: + /api/v1/users/me/videos/imports: get: summary: Get video imports of my user security: @@ -1306,7 +1630,7 @@ paths: schema: $ref: '#/components/schemas/VideoImportsList' - /users/me/video-quota-used: + /api/v1/users/me/video-quota-used: get: summary: Get my user used quota security: @@ -1331,7 +1655,7 @@ paths: description: The user video quota used today in bytes example: 1681014151 - '/users/me/videos/{videoId}/rating': + '/api/v1/users/me/videos/{videoId}/rating': get: summary: Get rate of my user for a video security: @@ -1354,7 +1678,7 @@ paths: schema: $ref: '#/components/schemas/GetMeVideoRating' - /users/me/videos: + /api/v1/users/me/videos: get: summary: Get videos of my user security: @@ -1375,7 +1699,7 @@ paths: schema: $ref: '#/components/schemas/VideoListResponse' - /users/me/subscriptions: + /api/v1/users/me/subscriptions: get: summary: Get my user subscriptions security: @@ -1421,7 +1745,7 @@ paths: '200': description: successful operation - /users/me/subscriptions/exist: + /api/v1/users/me/subscriptions/exist: get: summary: Get if subscriptions exist for my user security: @@ -1439,7 +1763,7 @@ paths: schema: type: object - /users/me/subscriptions/videos: + /api/v1/users/me/subscriptions/videos: get: summary: List videos of subscriptions of my user security: @@ -1473,7 +1797,7 @@ paths: schema: $ref: '#/components/schemas/VideoListResponse' - '/users/me/subscriptions/{subscriptionHandle}': + '/api/v1/users/me/subscriptions/{subscriptionHandle}': get: summary: Get subscription of my user security: @@ -1503,7 +1827,7 @@ paths: '200': description: successful operation - /users/me/notifications: + /api/v1/users/me/notifications: get: summary: List my notifications security: @@ -1527,7 +1851,7 @@ paths: schema: $ref: '#/components/schemas/NotificationListResponse' - /users/me/notifications/read: + /api/v1/users/me/notifications/read: post: summary: Mark notifications as read by their id security: @@ -1551,7 +1875,7 @@ paths: '204': description: successful operation - /users/me/notifications/read-all: + /api/v1/users/me/notifications/read-all: post: summary: Mark all my notification as read security: @@ -1562,7 +1886,7 @@ paths: '204': description: successful operation - /users/me/notification-settings: + /api/v1/users/me/notification-settings: put: summary: Update my notification settings security: @@ -1603,7 +1927,7 @@ paths: '204': description: successful operation - /users/me/history/videos: + /api/v1/users/me/history/videos: get: summary: List watched videos history security: @@ -1622,7 +1946,7 @@ paths: schema: $ref: '#/components/schemas/VideoListResponse' - /users/me/history/videos/{videoId}: + /api/v1/users/me/history/videos/{videoId}: delete: summary: Delete history element security: @@ -1639,7 +1963,7 @@ paths: '204': description: successful operation - /users/me/history/videos/remove: + /api/v1/users/me/history/videos/remove: post: summary: Clear video history security: @@ -1660,7 +1984,7 @@ paths: '204': description: successful operation - /users/me/avatar/pick: + /api/v1/users/me/avatar/pick: post: summary: Update my user avatar security: @@ -1701,7 +2025,7 @@ paths: avatarfile: contentType: image/png, image/jpeg - /users/me/avatar: + /api/v1/users/me/avatar: delete: summary: Delete my avatar security: @@ -1712,7 +2036,7 @@ paths: '204': description: successful operation - /videos/ownership: + /api/v1/videos/ownership: get: summary: List video ownership changes tags: @@ -1723,7 +2047,7 @@ paths: '200': description: successful operation - '/videos/ownership/{id}/accept': + '/api/v1/videos/ownership/{id}/accept': post: summary: Accept ownership change request tags: @@ -1740,7 +2064,7 @@ paths: '404': description: video ownership change not found - '/videos/ownership/{id}/refuse': + '/api/v1/videos/ownership/{id}/refuse': post: summary: Refuse ownership change request tags: @@ -1757,7 +2081,7 @@ paths: '404': description: video ownership change not found - '/videos/{id}/give-ownership': + '/api/v1/videos/{id}/give-ownership': post: summary: Request ownership change tags: @@ -1785,7 +2109,30 @@ paths: '404': description: video not found - /videos/{id}/studio/edit: + '/api/v1/videos/{id}/token': + post: + summary: Request video token + operationId: requestVideoToken + description: Request special tokens that expire quickly to use them in some context (like accessing private static files) + tags: + - Video + security: + - OAuth2: [] + parameters: + - $ref: '#/components/parameters/idOrUUID' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/VideoTokenResponse' + '400': + description: incorrect parameters + '404': + description: video not found + + /api/v1/videos/{id}/studio/edit: post: summary: Create a studio task tags: @@ -1810,7 +2157,7 @@ paths: '404': description: video not found - /videos: + /api/v1/videos: get: summary: List videos operationId: getVideos @@ -1841,7 +2188,7 @@ paths: schema: $ref: '#/components/schemas/VideoListResponse' - /videos/categories: + /api/v1/videos/categories: get: summary: List available video categories operationId: getCategories @@ -1860,7 +2207,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/videos/categories - /videos/licences: + /api/v1/videos/licences: get: summary: List available video licences operationId: getLicences @@ -1879,7 +2226,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/videos/licences - /videos/languages: + /api/v1/videos/languages: get: summary: List available video languages operationId: getLanguages @@ -1898,7 +2245,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/videos/languages - /videos/privacies: + /api/v1/videos/privacies: get: summary: List available video privacy policies operationId: getPrivacyPolicies @@ -1917,7 +2264,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/videos/privacies - '/videos/{id}': + '/api/v1/videos/{id}': put: summary: Update a video operationId: putVideo @@ -2023,7 +2370,7 @@ paths: '204': description: successful operation - '/videos/{id}/description': + '/api/v1/videos/{id}/description': get: summary: Get complete video description operationId: getVideoDesc @@ -2044,7 +2391,7 @@ paths: example: | **[Want to help to translate this video?](https://weblate.framasoft.org/projects/what-is-peertube-video/)**\r\n\r\n**Take back the control of your videos! [#JoinPeertube](https://joinpeertube.org)** - '/videos/{id}/source': + '/api/v1/videos/{id}/source': post: summary: Get video source file metadata operationId: getVideoSource @@ -2060,7 +2407,7 @@ paths: schema: $ref: '#/components/schemas/VideoSource' - '/videos/{id}/views': + '/api/v1/videos/{id}/views': post: summary: Notify user is watching a video description: Call this endpoint regularly (every 5-10 seconds for example) to notify the server the user is watching the video. After a while, PeerTube will increase video's viewers counter. If the user is authenticated, PeerTube will also store the current player time. @@ -2079,7 +2426,7 @@ paths: '204': description: successful operation - '/videos/{id}/watching': + '/api/v1/videos/{id}/watching': put: summary: Set watching progress of a video deprecated: true @@ -2100,7 +2447,7 @@ paths: '204': description: successful operation - '/videos/{id}/stats/overall': + '/api/v1/videos/{id}/stats/overall': get: summary: Get overall stats of a video tags: @@ -2129,7 +2476,7 @@ paths: schema: $ref: '#/components/schemas/VideoStatsOverall' - '/videos/{id}/stats/retention': + '/api/v1/videos/{id}/stats/retention': get: summary: Get retention stats of a video tags: @@ -2146,7 +2493,7 @@ paths: schema: $ref: '#/components/schemas/VideoStatsRetention' - '/videos/{id}/stats/timeseries/{metric}': + '/api/v1/videos/{id}/stats/timeseries/{metric}': get: summary: Get timeserie stats of a video tags: @@ -2185,7 +2532,7 @@ paths: schema: $ref: '#/components/schemas/VideoStatsTimeserie' - /videos/upload: + /api/v1/videos/upload: post: summary: Upload a video description: Uses a single request to upload a video. @@ -2263,7 +2610,7 @@ paths: --form channelId=$CHANNEL_ID \ --form name="$NAME" - /videos/upload-resumable: + /api/v1/videos/upload-resumable: post: summary: Initialize the resumable upload of a video description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the upload of a video @@ -2437,7 +2784,7 @@ paths: '404': description: upload not found - /videos/imports: + /api/v1/videos/imports: post: summary: Import a video description: Import a torrent or magnetURI or HTTP resource (if enabled by the instance administrator) @@ -2473,7 +2820,7 @@ paths: '409': description: HTTP or Torrent/magnetURI import not enabled - /videos/imports/{id}/cancel: + /api/v1/videos/imports/{id}/cancel: post: summary: Cancel video import description: Cancel a pending video import @@ -2487,7 +2834,7 @@ paths: '204': description: successful operation - /videos/imports/{id}: + /api/v1/videos/imports/{id}: delete: summary: Delete video import description: Delete ended video import @@ -2501,7 +2848,7 @@ paths: '204': description: successful operation - /videos/live: + /api/v1/videos/live: post: summary: Create a live operationId: addLive @@ -2603,7 +2950,7 @@ paths: previewfile: contentType: image/jpeg - /videos/live/{id}: + /api/v1/videos/live/{id}: get: summary: Get information about a live operationId: getLiveId @@ -2643,7 +2990,7 @@ paths: description: bad parameters or trying to update a live that has already started '403': description: trying to save replay of the live but saving replay is not enabled on the instance - /videos/live/{id}/sessions: + /api/v1/videos/live/{id}/sessions: get: summary: List live sessions description: List all sessions created in a particular live @@ -2668,7 +3015,7 @@ paths: type: array items: $ref: '#/components/schemas/LiveVideoSessionResponse' - /videos/{id}/live-session: + /api/v1/videos/{id}/live-session: get: summary: Get live session of a replay description: If the video is a replay of a live, you can find the associated live session using this endpoint @@ -2686,7 +3033,7 @@ paths: schema: $ref: '#/components/schemas/LiveVideoSessionResponse' - /users/me/abuses: + /api/v1/users/me/abuses: get: summary: List my abuses operationId: getMyAbuses @@ -2724,7 +3071,7 @@ paths: items: $ref: '#/components/schemas/Abuse' - /abuses: + /api/v1/abuses: get: summary: List abuses operationId: getAbuses @@ -2877,7 +3224,7 @@ paths: '400': description: incorrect request parameters - '/abuses/{abuseId}': + '/api/v1/abuses/{abuseId}': put: summary: Update an abuse security: @@ -2922,7 +3269,7 @@ paths: '404': description: block not found - '/abuses/{abuseId}/messages': + '/api/v1/abuses/{abuseId}/messages': get: summary: List messages of an abuse security: @@ -2974,7 +3321,7 @@ paths: '400': description: incorrect request parameters - '/abuses/{abuseId}/messages/{abuseMessageId}': + '/api/v1/abuses/{abuseId}/messages/{abuseMessageId}': delete: summary: Delete an abuse message security: @@ -2988,7 +3335,7 @@ paths: '204': description: successful operation - '/videos/{id}/blacklist': + '/api/v1/videos/{id}/blacklist': post: summary: Block a video operationId: addVideoBlock @@ -3020,7 +3367,7 @@ paths: '404': description: block not found - /videos/blacklist: + /api/v1/videos/blacklist: get: tags: - Video Blocks @@ -3068,7 +3415,7 @@ paths: items: $ref: '#/components/schemas/VideoBlacklist' - /videos/{id}/captions: + /api/v1/videos/{id}/captions: get: summary: List captions of a video operationId: getVideoCaptions @@ -3092,7 +3439,7 @@ paths: items: $ref: '#/components/schemas/VideoCaption' - /videos/{id}/captions/{captionLanguage}: + /api/v1/videos/{id}/captions/{captionLanguage}: put: summary: Add or replace a video caption operationId: addVideoCaption @@ -3139,7 +3486,7 @@ paths: '404': description: video or language or caption for that language not found - /video-channels: + /api/v1/video-channels: get: summary: List video channels operationId: getVideoChannels @@ -3182,7 +3529,7 @@ paths: schema: $ref: '#/components/schemas/VideoChannelCreate' - '/video-channels/{channelHandle}': + '/api/v1/video-channels/{channelHandle}': get: summary: Get a video channel operationId: getVideoChannel @@ -3227,7 +3574,7 @@ paths: '204': description: successful operation - '/video-channels/{channelHandle}/videos': + '/api/v1/video-channels/{channelHandle}/videos': get: summary: List videos of a video channel operationId: getVideoChannelVideos @@ -3260,7 +3607,7 @@ paths: schema: $ref: '#/components/schemas/VideoListResponse' - '/video-channels/{channelHandle}/followers': + '/api/v1/video-channels/{channelHandle}/followers': get: tags: - Video Channels @@ -3290,7 +3637,7 @@ paths: items: $ref: '#/components/schemas/Follow' - '/video-channels/{channelHandle}/avatar/pick': + '/api/v1/video-channels/{channelHandle}/avatar/pick': post: summary: Update channel avatar security: @@ -3333,7 +3680,7 @@ paths: avatarfile: contentType: image/png, image/jpeg - '/video-channels/{channelHandle}/avatar': + '/api/v1/video-channels/{channelHandle}/avatar': delete: summary: Delete channel avatar security: @@ -3346,7 +3693,7 @@ paths: '204': description: successful operation - '/video-channels/{channelHandle}/banner/pick': + '/api/v1/video-channels/{channelHandle}/banner/pick': post: summary: Update channel banner security: @@ -3389,7 +3736,7 @@ paths: bannerfile: contentType: image/png, image/jpeg - '/video-channels/{channelHandle}/banner': + '/api/v1/video-channels/{channelHandle}/banner': delete: summary: Delete channel banner security: @@ -3402,7 +3749,7 @@ paths: '204': description: successful operation - '/video-channels/{channelHandle}/import-videos': + '/api/v1/video-channels/{channelHandle}/import-videos': post: summary: Import videos in channel description: Import a remote channel/playlist videos into a channel @@ -3422,7 +3769,7 @@ paths: '204': description: successful operation - '/video-channel-syncs': + '/api/v1/video-channel-syncs': post: summary: Create a synchronization for a video channel operationId: addVideoChannelSync @@ -3446,7 +3793,7 @@ paths: videoChannelSync: $ref: "#/components/schemas/VideoChannelSync" - '/video-channel-syncs/{channelSyncId}': + '/api/v1/video-channel-syncs/{channelSyncId}': delete: summary: Delete a video channel synchronization operationId: delVideoChannelSync @@ -3460,7 +3807,7 @@ paths: '204': description: successful operation - '/video-channel-syncs/{channelSyncId}/sync': + '/api/v1/video-channel-syncs/{channelSyncId}/sync': post: summary: Triggers the channel synchronization job, fetching all the videos from the remote channel operationId: triggerVideoChannelSync @@ -3475,7 +3822,7 @@ paths: description: successful operation - /video-playlists/privacies: + /api/v1/video-playlists/privacies: get: summary: List available playlist privacy policies operationId: getPlaylistPrivacyPolicies @@ -3494,7 +3841,7 @@ paths: nightly: externalValue: https://peertube2.cpy.re/api/v1/video-playlists/privacies - /video-playlists: + /api/v1/video-playlists: get: summary: List video playlists operationId: getPlaylists @@ -3576,7 +3923,7 @@ paths: thumbnailfile: contentType: image/jpeg - /video-playlists/{playlistId}: + /api/v1/video-playlists/{playlistId}: get: summary: Get a video playlist tags: @@ -3641,7 +3988,7 @@ paths: '204': description: successful operation - /video-playlists/{playlistId}/videos: + /api/v1/video-playlists/{playlistId}/videos: get: summary: 'List videos of a playlist' operationId: getVideoPlaylistVideos @@ -3705,7 +4052,7 @@ paths: required: - videoId - /video-playlists/{playlistId}/videos/reorder: + /api/v1/video-playlists/{playlistId}/videos/reorder: post: summary: 'Reorder a playlist' operationId: reorderVideoPlaylist @@ -3740,7 +4087,7 @@ paths: - startPosition - insertAfterPosition - /video-playlists/{playlistId}/videos/{playlistElementId}: + /api/v1/video-playlists/{playlistId}/videos/{playlistElementId}: put: summary: Update a playlist element operationId: putVideoPlaylistVideo @@ -3782,7 +4129,7 @@ paths: '204': description: successful operation - '/users/me/video-playlists/videos-exist': + '/api/v1/users/me/video-playlists/videos-exist': get: summary: Check video exists in my playlists security: @@ -3822,7 +4169,7 @@ paths: type: integer format: seconds - '/accounts/{name}/video-channels': + '/api/v1/accounts/{name}/video-channels': get: summary: List video channels of an account tags: @@ -3846,7 +4193,7 @@ paths: schema: $ref: '#/components/schemas/VideoChannelList' - '/accounts/{name}/video-channel-syncs': + '/api/v1/accounts/{name}/video-channel-syncs': get: summary: List the synchronizations of video channels of an account tags: @@ -3866,7 +4213,7 @@ paths: schema: $ref: '#/components/schemas/VideoChannelSyncList' - '/accounts/{name}/ratings': + '/api/v1/accounts/{name}/ratings': get: summary: List ratings of an account security: @@ -3897,7 +4244,7 @@ paths: items: $ref: '#/components/schemas/VideoRating' - '/videos/{id}/comment-threads': + '/api/v1/videos/{id}/comment-threads': get: summary: List threads of a video tags: @@ -3945,7 +4292,7 @@ paths: required: - text - '/videos/{id}/comment-threads/{threadId}': + '/api/v1/videos/{id}/comment-threads/{threadId}': get: summary: Get a thread tags: @@ -3961,7 +4308,7 @@ paths: schema: $ref: '#/components/schemas/VideoCommentThreadTree' - '/videos/{id}/comments/{commentId}': + '/api/v1/videos/{id}/comments/{commentId}': post: summary: Reply to a thread of a video security: @@ -4012,7 +4359,7 @@ paths: '409': description: comment is already deleted - '/videos/{id}/rate': + '/api/v1/videos/{id}/rate': put: summary: Like/dislike a video security: @@ -4040,7 +4387,7 @@ paths: '404': description: video does not exist - '/videos/{id}/hls': + '/api/v1/videos/{id}/hls': delete: summary: Delete video HLS files security: @@ -4056,7 +4403,7 @@ paths: description: successful operation '404': description: video does not exist - '/videos/{id}/webtorrent': + '/api/v1/videos/{id}/webtorrent': delete: summary: Delete video WebTorrent files security: @@ -4073,7 +4420,7 @@ paths: '404': description: video does not exist - '/videos/{id}/transcoding': + '/api/v1/videos/{id}/transcoding': post: summary: Create a transcoding job security: @@ -4103,7 +4450,7 @@ paths: '404': description: video does not exist - /search/videos: + /api/v1/search/videos: get: tags: - Search @@ -4184,7 +4531,7 @@ paths: '500': description: search index unavailable - /search/video-channels: + /api/v1/search/video-channels: get: tags: - Search @@ -4217,7 +4564,7 @@ paths: '500': description: search index unavailable - /search/video-playlists: + /api/v1/search/video-playlists: get: tags: - Search @@ -4258,7 +4605,7 @@ paths: '500': description: search index unavailable - /blocklist/status: + /api/v1/blocklist/status: get: tags: - Account Blocks @@ -4291,7 +4638,7 @@ paths: schema: $ref: '#/components/schemas/BlockStatus' - /server/blocklist/accounts: + /api/v1/server/blocklist/accounts: get: tags: - Account Blocks @@ -4331,7 +4678,7 @@ paths: '409': description: self-blocking forbidden - '/server/blocklist/accounts/{accountName}': + '/api/v1/server/blocklist/accounts/{accountName}': delete: tags: - Account Blocks @@ -4352,7 +4699,7 @@ paths: '404': description: account or account block does not exist - /server/blocklist/servers: + /api/v1/server/blocklist/servers: get: tags: - Server Blocks @@ -4392,7 +4739,7 @@ paths: '409': description: self-blocking forbidden - '/server/blocklist/servers/{host}': + '/api/v1/server/blocklist/servers/{host}': delete: tags: - Server Blocks @@ -4414,7 +4761,7 @@ paths: '404': description: account block does not exist - /server/redundancy/{host}: + /api/v1/server/redundancy/{host}: put: tags: - Instance Redundancy @@ -4447,7 +4794,7 @@ paths: '404': description: server is not already known - /server/redundancy/videos: + /api/v1/server/redundancy/videos: get: tags: - Video Mirroring @@ -4506,7 +4853,7 @@ paths: '409': description: video is already mirrored - /server/redundancy/videos/{redundancyId}: + /api/v1/server/redundancy/videos/{redundancyId}: delete: tags: - Video Mirroring @@ -4528,7 +4875,7 @@ paths: '404': description: video redundancy not found - /server/stats: + /api/v1/server/stats: get: tags: - Stats @@ -4543,7 +4890,7 @@ paths: schema: $ref: '#/components/schemas/ServerStats' - /server/logs/client: + /api/v1/server/logs/client: post: tags: - Logs @@ -4558,7 +4905,7 @@ paths: '204': description: successful operation - /server/logs: + /api/v1/server/logs: get: tags: - Logs @@ -4577,7 +4924,7 @@ paths: items: type: string - /server/audit-logs: + /api/v1/server/audit-logs: get: tags: - Logs @@ -4596,262 +4943,7 @@ paths: items: type: string - '/feeds/video-comments.{format}': - get: - tags: - - Feeds - summary: List comments on videos - operationId: getSyndicatedComments - parameters: - - name: format - in: path - required: true - description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' - schema: - type: string - enum: - - xml - - rss - - rss2 - - atom - - atom1 - - json - - json1 - - name: videoId - in: query - description: 'limit listing to a specific video' - schema: - type: string - - name: accountId - in: query - description: 'limit listing to a specific account' - schema: - type: string - - name: accountName - in: query - description: 'limit listing to a specific account' - schema: - type: string - - name: videoChannelId - in: query - description: 'limit listing to a specific video channel' - schema: - type: string - - name: videoChannelName - in: query - description: 'limit listing to a specific video channel' - schema: - type: string - responses: - '204': - description: successful operation - headers: - Cache-Control: - schema: - type: string - default: 'max-age=900' # 15 min cache - content: - application/xml: - schema: - $ref: '#/components/schemas/VideoCommentsForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/video-comments.xml?filter=local - application/rss+xml: - schema: - $ref: '#/components/schemas/VideoCommentsForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/video-comments.rss?filter=local - text/xml: - schema: - $ref: '#/components/schemas/VideoCommentsForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/video-comments.xml?filter=local - application/atom+xml: - schema: - $ref: '#/components/schemas/VideoCommentsForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/video-comments.atom?filter=local - application/json: - schema: - type: object - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/video-comments.json?filter=local - '400': - x-summary: field inconsistencies - description: > - Arises when: - - videoId filter is mixed with a channel filter - '404': - description: video, video channel or account not found - '406': - description: accept header unsupported - - '/feeds/videos.{format}': - get: - tags: - - Feeds - summary: List videos - operationId: getSyndicatedVideos - parameters: - - name: format - in: path - required: true - description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' - schema: - type: string - enum: - - xml - - rss - - rss2 - - atom - - atom1 - - json - - json1 - - name: accountId - in: query - description: 'limit listing to a specific account' - schema: - type: string - - name: accountName - in: query - description: 'limit listing to a specific account' - schema: - type: string - - name: videoChannelId - in: query - description: 'limit listing to a specific video channel' - schema: - type: string - - name: videoChannelName - in: query - description: 'limit listing to a specific video channel' - schema: - type: string - - $ref: '#/components/parameters/sort' - - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/isLocal' - - $ref: '#/components/parameters/include' - - $ref: '#/components/parameters/privacyOneOf' - - $ref: '#/components/parameters/hasHLSFiles' - - $ref: '#/components/parameters/hasWebtorrentFiles' - responses: - '204': - description: successful operation - headers: - Cache-Control: - schema: - type: string - default: 'max-age=900' # 15 min cache - content: - application/xml: - schema: - $ref: '#/components/schemas/VideosForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/videos.xml?filter=local - application/rss+xml: - schema: - $ref: '#/components/schemas/VideosForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/videos.rss?filter=local - text/xml: - schema: - $ref: '#/components/schemas/VideosForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/videos.xml?filter=local - application/atom+xml: - schema: - $ref: '#/components/schemas/VideosForXML' - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/videos.atom?filter=local - application/json: - schema: - type: object - examples: - nightly: - externalValue: https://peertube2.cpy.re/feeds/videos.json?filter=local - '404': - description: video channel or account not found - '406': - description: accept header unsupported - - '/feeds/subscriptions.{format}': - get: - tags: - - Feeds - - Account - summary: List videos of subscriptions tied to a token - operationId: getSyndicatedSubscriptionVideos - parameters: - - name: format - in: path - required: true - description: 'format expected (we focus on making `rss` the most featureful ; it serves [Media RSS](https://www.rssboard.org/media-rss))' - schema: - type: string - enum: - - xml - - rss - - rss2 - - atom - - atom1 - - json - - json1 - - name: accountId - in: query - description: limit listing to a specific account - schema: - type: string - required: true - - name: token - in: query - description: private token allowing access - schema: - type: string - required: true - - $ref: '#/components/parameters/sort' - - $ref: '#/components/parameters/nsfw' - - $ref: '#/components/parameters/isLocal' - - $ref: '#/components/parameters/include' - - $ref: '#/components/parameters/privacyOneOf' - - $ref: '#/components/parameters/hasHLSFiles' - - $ref: '#/components/parameters/hasWebtorrentFiles' - responses: - '204': - description: successful operation - headers: - Cache-Control: - schema: - type: string - default: 'max-age=900' # 15 min cache - content: - application/xml: - schema: - $ref: '#/components/schemas/VideosForXML' - application/rss+xml: - schema: - $ref: '#/components/schemas/VideosForXML' - text/xml: - schema: - $ref: '#/components/schemas/VideosForXML' - application/atom+xml: - schema: - $ref: '#/components/schemas/VideosForXML' - application/json: - schema: - type: object - '406': - description: accept header unsupported - - /plugins: + /api/v1/plugins: get: tags: - Plugins @@ -4880,7 +4972,7 @@ paths: schema: $ref: '#/components/schemas/PluginResponse' - /plugins/available: + /api/v1/plugins/available: get: tags: - Plugins @@ -4915,7 +5007,7 @@ paths: '503': description: plugin index unavailable - /plugins/install: + /api/v1/plugins/install: post: tags: - Plugins @@ -4950,7 +5042,7 @@ paths: '400': description: should have either `npmName` or `path` set - /plugins/update: + /api/v1/plugins/update: post: tags: - Plugins @@ -4987,7 +5079,7 @@ paths: '404': description: existing plugin not found - /plugins/uninstall: + /api/v1/plugins/uninstall: post: tags: - Plugins @@ -5014,7 +5106,7 @@ paths: '404': description: existing plugin not found - /plugins/{npmName}: + /api/v1/plugins/{npmName}: get: tags: - Plugins @@ -5035,7 +5127,7 @@ paths: '404': description: plugin not found - /plugins/{npmName}/settings: + /api/v1/plugins/{npmName}/settings: put: tags: - Plugins @@ -5060,7 +5152,7 @@ paths: '404': description: plugin not found - /plugins/{npmName}/public-settings: + /api/v1/plugins/{npmName}/public-settings: get: tags: - Plugins @@ -5078,7 +5170,7 @@ paths: '404': description: plugin not found - /plugins/{npmName}/registered-settings: + /api/v1/plugins/{npmName}/registered-settings: get: tags: - Plugins @@ -5099,7 +5191,7 @@ paths: '404': description: plugin not found - /metrics/playback: + /api/v1/metrics/playback: post: summary: Create playback metrics description: These metrics are exposed by OpenTelemetry metrics exporter if enabled. @@ -5115,11 +5207,11 @@ paths: description: successful operation servers: - - url: 'https://peertube2.cpy.re/api/v1' + - url: 'https://peertube2.cpy.re' description: Live Test Server (live data - latest nightly version) - - url: 'https://peertube3.cpy.re/api/v1' + - url: 'https://peertube3.cpy.re' description: Live Test Server (live data - latest RC version) - - url: 'https://peertube.cpy.re/api/v1' + - url: 'https://peertube.cpy.re' description: Live Test Server (live data - stable version) components: parameters: @@ -5596,6 +5688,22 @@ components: - Group - Service - Organization + staticFilename: + name: filename + in: path + required: true + description: Filename + schema: + type: string + videoFileToken: + name: videoFileToken + in: query + required: false + 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 + + securitySchemes: OAuth2: description: | @@ -7349,6 +7457,16 @@ components: properties: comment: $ref: '#/components/schemas/VideoComment' + VideoTokenResponse: + properties: + files: + type: object + properties: + token: + type: string + expires: + type: string + format: date-time VideoListResponse: properties: total: diff --git a/support/nginx/peertube b/support/nginx/peertube index f6f754b58..cf200ba00 100644 --- a/support/nginx/peertube +++ b/support/nginx/peertube @@ -214,6 +214,10 @@ server { try_files $uri @api; } + location ~ ^/static/(webseed|streaming-playlists)/private/ { + try_files /dev/null @api; + } + # Bypass PeerTube for performance reasons. Optional. location ~ ^/static/(webseed|redundancy|streaming-playlists)/ { limit_rate_after 5M; diff --git a/yarn.lock b/yarn.lock index 8ccc4fd0d..64966f808 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1921,11 +1921,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== -"@types/async-lock@^1.1.0": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.5.tgz#a82f33e09aef451d6ded7bffae73f9d254723124" - integrity sha512-A9ClUfmj6wwZMLRz0NaYzb98YH1exlHdf/cdDSKBfMQJnPOdO8xlEW0Eh2QsTTntGzOFWURcEjYElkZ1IY4GCQ== - "@types/bcrypt@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-5.0.0.tgz#a835afa2882d165aff5690893db314eaa98b9f20" @@ -2762,6 +2757,13 @@ async-lru@^1.1.1: dependencies: lru "^3.1.0" +async-mutex@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.0.tgz#ae8048cd4d04ace94347507504b3cf15e631c25f" + integrity sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA== + dependencies: + tslib "^2.4.0" + async@3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" @@ -8975,7 +8977,7 @@ tslib@^1.11.1, tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.1: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.1, tslib@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==