diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts
index 4b9357fb7..195b265a1 100644
--- a/client/src/app/+admin/overview/videos/video-admin.service.ts
+++ b/client/src/app/+admin/overview/videos/video-admin.service.ts
@@ -151,7 +151,7 @@ export class VideoAdminService {
}
if (filters.excludePublic) {
- privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ]
+ privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
filters.excludePublic = undefined
}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
index 97ffb6013..393c3ad6b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
async disableTwoFactor () {
const message = $localize`Are you sure you want to disable two factor authentication of your account?`
- const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
+ const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` })
if (confirmed === false) return
this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index b607dabe9..97b713874 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -120,7 +120,12 @@
-
@@ -92,6 +92,7 @@
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 19ad97d42..aba3ee086 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -25,7 +25,7 @@ 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, videoRequiresAuth } from '@root-helpers/video'
+import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video'
import { timeToInt } from '@shared/core-utils'
import {
HTMLServerConfig,
@@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null
videoCaptions: VideoCaption[] = []
liveVideo: LiveVideo
+ videoPassword: string
playlistPosition: number
playlist: VideoPlaylist = null
@@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.authService.isLoggedIn()
}
+ isUserOwner () {
+ return this.video.isLocal === true && this.video.account.name === this.user?.username
+ }
+
isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.user, this.serverConfig)
}
@@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private loadVideo (options: {
videoId: string
forceAutoplay: boolean
+ videoPassword?: string
}) {
- const { videoId, forceAutoplay } = options
+ const { videoId, forceAutoplay, videoPassword } = options
if (this.isSameElement(this.video, videoId)) return
@@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const videoObs = this.hooks.wrapObsFun(
this.videoService.getVideo.bind(this.videoService),
- { videoId },
+ { videoId, videoPassword },
'video-watch',
'filter:api.video-watch.video.get.params',
'filter:api.video-watch.video.get.result'
@@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}),
switchMap(({ video, live }) => {
- if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined })
+ if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined })
- return this.videoFileTokenService.getVideoFileToken(video.uuid)
+ return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword })
.pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
})
)
forkJoin([
videoAndLiveObs,
- this.videoCaptionService.listCaptions(videoId),
+ this.videoCaptionService.listCaptions(videoId, videoPassword),
this.userService.getAnonymousOrLoggedUser()
]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
@@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
live,
videoCaptions: captionsResult.data,
videoFileToken,
+ videoPassword,
loggedInOrAnonymousUser,
urlOptions,
forceAutoplay
- }).catch(err => this.handleGlobalError(err))
+ }).catch(err => {
+ this.handleGlobalError(err)
+ })
},
+ error: async err => {
+ if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
+ const { confirmed, password } = await this.handleVideoPasswordError(err)
- error: err => this.handleRequestError(err)
+ if (confirmed === false) return this.location.back()
+
+ this.loadVideo({ ...options, videoPassword: password })
+ } else {
+ this.handleRequestError(err)
+ }
+ }
})
}
@@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.notifier.error(errorMessage)
}
+ private handleVideoPasswordError (err: any) {
+ let isIncorrectPassword: boolean
+
+ if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) {
+ isIncorrectPassword = false
+ } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
+ this.videoPassword = undefined
+ isIncorrectPassword = true
+ }
+
+ return this.confirmService.confirmWithPassword({
+ message: $localize`You need a password to watch this video`,
+ title: $localize`This video is password protected`,
+ errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : ''
+ })
+ }
+
private async onVideoFetched (options: {
video: VideoDetails
live: LiveVideo
videoCaptions: VideoCaption[]
videoFileToken: string
+ videoPassword: string
urlOptions: URLOptions
loggedInOrAnonymousUser: User
forceAutoplay: boolean
}) {
- const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options
+ const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options
this.subscribeToLiveEventsIfNeeded(this.video, video)
@@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoCaptions = videoCaptions
this.liveVideo = live
this.videoFileToken = videoFileToken
+ this.videoPassword = videoPassword
// Re init attributes
this.playerPlaceholderImgSrc = undefined
@@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: this.videoCaptions,
liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken,
+ videoPassword: this.videoPassword,
urlOptions,
loggedInOrAnonymousUser,
forceAutoplay,
@@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: VideoCaption[]
videoFileToken: string
+ videoPassword: string
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
@@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
forceAutoplay: boolean
user?: AuthUser // Keep for plugins
}) {
- const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
+ const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
const getStartTime = () => {
const byUrl = urlOptions.startTime !== undefined
@@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
serverUrl: environment.originServerUrl || window.location.origin,
videoFileToken: () => videoFileToken,
- requiresAuth: videoRequiresAuth(video),
+ requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
+ requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
+ !video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
+ videoPassword: () => videoPassword,
videoCaptions: playerCaptions,
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 89a25f0a5..abe163aae 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
type ConfirmOptions = {
title: string
message: string
+ errorMessage?: string
} & (
{
type: 'confirm'
@@ -12,6 +13,7 @@ type ConfirmOptions = {
{
type: 'confirm-password'
confirmButtonText?: string
+ isIncorrectPassword?: boolean
} |
{
type: 'confirm-expected-input'
@@ -32,8 +34,14 @@ export class ConfirmService {
return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
}
- confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
- this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
+ confirmWithPassword (options: {
+ message: string
+ title?: string
+ confirmButtonText?: string
+ errorMessage?: string
+ }) {
+ const { message, title = '', confirmButtonText, errorMessage } = options
+ this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage })
const obs = this.confirmResponse.asObservable()
.pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index 6584db3e6..33696d0a5 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -12,10 +12,12 @@
-
+
-
+
+
+ {{ errorMessage }}
+
+ This video is password protected, please note that recipients will require the corresponding password to access the content.
+
+
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts
index 32f900f15..da4f2a4b4 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.ts
+++ b/client/src/app/shared/shared-share-modal/video-share.component.ts
@@ -243,6 +243,10 @@ export class VideoShareComponent {
return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
}
+ isPasswordProtectedVideo () {
+ return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
+ }
+
private getPlaylistOptions (baseUrl?: string) {
return {
baseUrl,
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 8d2deedf7..3906652be 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -18,6 +18,7 @@ import {
import { environment } from '../../../environments/environment'
import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model'
+import { VideoPasswordService } from '../shared-main'
@Injectable()
export class VideoCommentService {
@@ -31,22 +32,25 @@ export class VideoCommentService {
private restService: RestService
) {}
- addCommentThread (videoId: string, comment: VideoCommentCreate) {
+ addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) {
+ const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
const normalizedComment = objectLineFeedToHtml(comment, 'text')
- return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
+ return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
.pipe(
map(data => this.extractVideoComment(data.comment)),
catchError(err => this.restExtractor.handleError(err))
)
}
- addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) {
+ addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) {
+ const { videoId, inReplyToCommentId, comment, videoPassword } = options
+ const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
const normalizedComment = objectLineFeedToHtml(comment, 'text')
- return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
+ return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
.pipe(
map(data => this.extractVideoComment(data.comment)),
catchError(err => this.restExtractor.handleError(err))
@@ -76,10 +80,13 @@ export class VideoCommentService {
getVideoCommentThreads (parameters: {
videoId: string
+ videoPassword: string
componentPagination: ComponentPaginationLight
sort: string
}): Observable> {
- const { videoId, componentPagination, sort } = parameters
+ const { videoId, videoPassword, componentPagination, sort } = parameters
+
+ const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
const pagination = this.restService.componentToRestPagination(componentPagination)
@@ -87,7 +94,7 @@ export class VideoCommentService {
params = this.restService.addRestGetParams(params, pagination, sort)
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
- return this.authHttp.get>(url, { params })
+ return this.authHttp.get>(url, { params, headers })
.pipe(
map(result => this.extractVideoComments(result)),
catchError(err => this.restExtractor.handleError(err))
@@ -97,12 +104,14 @@ export class VideoCommentService {
getVideoThreadComments (parameters: {
videoId: string
threadId: number
+ videoPassword?: string
}): Observable {
- const { videoId, threadId } = parameters
+ const { videoId, threadId, videoPassword } = parameters
const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
+ const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp
- .get(url)
+ .get(url, { headers })
.pipe(
map(tree => this.extractVideoCommentTree(tree)),
catchError(err => this.restExtractor.handleError(err))
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 cac82d8d0..146ea7dfe 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -1,13 +1,13 @@
import { mapValues } from 'lodash-es'
import { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators'
-import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
+import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/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 { videoRequiresFileToken } from '@root-helpers/video'
import { objectKeysTyped, pick } from '@shared/core-utils'
-import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
+import { VideoCaption, VideoFile } from '@shared/models'
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
type DownloadType = 'video' | 'subtitles'
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef
+ @Input() videoPassword: string
+
downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | string = -1
@@ -89,8 +91,8 @@ export class VideoDownloadComponent {
this.subtitleLanguageId = this.videoCaptions[0].language.id
}
- if (videoRequiresAuth(this.video)) {
- this.videoFileTokenService.getVideoFileToken(this.video.uuid)
+ if (this.isConfidentialVideo()) {
+ this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => this.videoFileToken = token)
}
@@ -201,7 +203,8 @@ export class VideoDownloadComponent {
}
isConfidentialVideo () {
- return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
+ return videoRequiresFileToken(this.video)
+
}
switchToType (type: DownloadType) {
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index 3d39c6fdc..3fbfaed28 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -125,7 +125,7 @@
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 3f0180695..9e0a4f79b 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -5,6 +5,7 @@
>
Unlisted
Private
+ Password protected
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 2384b34d7..d453f37a1 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
return this.video.privacy.id === VideoPrivacy.PRIVATE
}
+ isPasswordProtectedVideo () {
+ return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
+ }
+
getStateLabel (video: Video) {
if (!video.state) return ''
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index 7b832263e..45df0be38 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -241,6 +241,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
}
reloadVideos () {
+ console.log('reload')
this.pagination.currentPage = 1
this.loadMoreVideos(true)
}
@@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
if (reset) this.videos = []
this.videos = this.videos.concat(data)
-
+ console.log('subscribe')
if (this.groupByDate) this.buildGroupedDateLabels()
this.onDataSubject.next(data)
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
index 75afa0709..882b14c5e 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
@@ -21,7 +21,8 @@
[attr.title]="playlistElement.video.name"
>{{ playlistElement.video.name }}
- Private
+ Private
+ Password protected
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
index 552ea742b..b9a1d9623 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
@@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
}
+ isVideoPasswordProtected () {
+ return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
+ }
+
isUnavailable (e: VideoPlaylistElement) {
return e.type === VideoPlaylistElementType.UNAVAILABLE
}
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 194991fa4..8091110bc 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
@@ -31,7 +31,7 @@ export class HLSOptionsBuilder {
const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
- requiresAuth: commonOptions.requiresAuth,
+ requiresUserAuth: commonOptions.requiresUserAuth,
videoFileToken: commonOptions.videoFileToken,
redundancyUrlManager,
@@ -88,17 +88,24 @@ export class HLSOptionsBuilder {
httpFailedSegmentTimeout: 1000,
xhrSetup: (xhr, url) => {
- if (!this.options.common.requiresAuth) return
+ const { requiresUserAuth, requiresPassword } = this.options.common
+
+ if (!(requiresUserAuth || requiresPassword)) return
+
if (!isSameOrigin(this.options.common.serverUrl, url)) return
- xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
+ if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword())
+
+ else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
},
segmentValidator: segmentValidatorFactory({
segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
authorizationHeader: this.options.common.authorizationHeader,
- requiresAuth: this.options.common.requiresAuth,
- serverUrl: this.options.common.serverUrl
+ requiresUserAuth: this.options.common.requiresUserAuth,
+ serverUrl: this.options.common.serverUrl,
+ requiresPassword: this.options.common.requiresPassword,
+ videoPassword: this.options.common.videoPassword
}),
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
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 b5bdcd4e6..80eec02cf 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
@@ -26,10 +26,10 @@ export class WebTorrentOptionsBuilder {
videoFileToken: commonOptions.videoFileToken,
- requiresAuth: commonOptions.requiresAuth,
+ requiresUserAuth: commonOptions.requiresUserAuth,
buildWebSeedUrls: file => {
- if (!commonOptions.requiresAuth) return []
+ if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return []
return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
},
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 44a31bfb4..e86d3d159 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
@@ -13,11 +13,20 @@ function segmentValidatorFactory (options: {
serverUrl: string
segmentsSha256Url: string
authorizationHeader: () => string
- requiresAuth: boolean
+ requiresUserAuth: boolean
+ requiresPassword: boolean
+ videoPassword: () => string
}) {
- const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options
+ const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options
- let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth })
+ let segmentsJSON = fetchSha256Segments({
+ serverUrl,
+ segmentsSha256Url,
+ authorizationHeader,
+ requiresUserAuth,
+ requiresPassword,
+ videoPassword
+ })
const regex = /bytes=(\d+)-(\d+)/
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
@@ -34,7 +43,14 @@ function segmentValidatorFactory (options: {
await wait(500)
- segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth })
+ segmentsJSON = fetchSha256Segments({
+ serverUrl,
+ segmentsSha256Url,
+ authorizationHeader,
+ requiresUserAuth,
+ requiresPassword,
+ videoPassword
+ })
await segmentValidator(segment, _method, _peerId, retry + 1)
return
@@ -78,13 +94,17 @@ function fetchSha256Segments (options: {
serverUrl: string
segmentsSha256Url: string
authorizationHeader: () => string
- requiresAuth: boolean
+ requiresUserAuth: boolean
+ requiresPassword: boolean
+ videoPassword: () => string
}): Promise {
- const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options
+ const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options
- const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url)
- ? { Authorization: authorizationHeader() }
- : {}
+ let headers: { [ id: string ]: string } = {}
+ if (isSameOrigin(serverUrl, segmentsSha256Url)) {
+ if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() }
+ else if (requiresUserAuth) headers = { Authorization: authorizationHeader() }
+ }
return fetch(segmentsSha256Url, { headers })
.then(res => res.json() as Promise)
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
index 3dde44a60..e2e220c03 100644
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
@@ -59,7 +59,7 @@ class WebTorrentPlugin extends Plugin {
private isAutoResolutionObservation = false
private playerRefusedP2P = false
- private requiresAuth: boolean
+ private requiresUserAuth: boolean
private videoFileToken: () => string
private torrentInfoInterval: any
@@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin {
this.savePlayerSrcFunction = this.player.src
this.playerElement = options.playerElement
- this.requiresAuth = options.requiresAuth
+ this.requiresUserAuth = options.requiresUserAuth
this.videoFileToken = options.videoFileToken
this.buildWebSeedUrls = options.buildWebSeedUrls
@@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin {
let httpUrl = this.currentVideoFile.fileUrl
- if (this.requiresAuth && this.videoFileToken) {
+ if (this.videoFileToken) {
httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
}
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index c14fd7e99..1f3a0aa2e 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -83,8 +83,10 @@ export interface CommonOptions extends CustomizationOptions {
videoShortUUID: string
serverUrl: string
- requiresAuth: boolean
+ requiresUserAuth: boolean
videoFileToken: () => string
+ requiresPassword: boolean
+ videoPassword: () => 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 eadf56cfa..723c42c5d 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -155,7 +155,7 @@ type WebtorrentPluginOptions = {
playerRefusedP2P: boolean
- requiresAuth: boolean
+ requiresUserAuth: boolean
videoFileToken: () => string
buildWebSeedUrls: (file: VideoFile) => string[]
@@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = {
loader: P2PMediaLoader
- requiresAuth: boolean
+ requiresUserAuth: boolean
videoFileToken: () => string
}
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts
index 9022b908b..4a44615fb 100644
--- a/client/src/root-helpers/video.ts
+++ b/client/src/root-helpers/video.ts
@@ -41,14 +41,21 @@ 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)
+function videoRequiresUserAuth (video: Video, videoPassword?: string) {
+ return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
+ (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
+
+}
+
+function videoRequiresFileToken (video: Video, videoPassword?: string) {
+ return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
}
export {
buildVideoOrPlaylistEmbed,
isP2PEnabled,
- videoRequiresAuth
+ videoRequiresUserAuth,
+ videoRequiresFileToken
}
// ---------------------------------------------------------------------------
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html
index 32bf5f655..a74bb4cee 100644
--- a/client/src/standalone/videos/embed.html
+++ b/client/src/standalone/videos/embed.html
@@ -41,6 +41,23 @@
+
+
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 3631ea7e6..d15887478 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -24,7 +24,7 @@ html,
body {
height: 100%;
margin: 0;
- background-color: #000;
+ background-color: #0f0f10;
}
#video-wrapper {
@@ -42,8 +42,10 @@ body {
}
}
-#error-block {
+#error-block,
+#video-password-block {
display: none;
+ user-select: none;
flex-direction: column;
align-content: center;
@@ -86,6 +88,43 @@ body {
text-align: center;
}
+#video-password-content {
+ @include margin(1rem, 0, 2rem);
+}
+
+#video-password-input,
+#video-password-submit {
+ line-height: 23px;
+ padding: 1rem;
+ margin: 1rem 0.5rem;
+ border: 0;
+ font-weight: 600;
+ border-radius: 3px!important;
+ font-size: 18px;
+ display: inline-block;
+}
+
+#video-password-submit {
+ color: #fff;
+ background-color: #f2690d;
+ cursor: pointer;
+}
+
+#video-password-submit:hover {
+ background-color: #f47825;
+}
+#video-password-error {
+ margin-top: 10px;
+ margin-bottom: 10px;
+ height: 2rem;
+ font-weight: bolder;
+}
+
+#video-password-block svg {
+ margin-left: auto;
+ margin-right: auto;
+}
+
@media screen and (max-width: 300px) {
#error-block {
font-size: 36px;
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index cc4274b99..cffda2cc7 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,10 +3,18 @@ import '../../assets/player/shared/dock/peertube-dock-component'
import '../../assets/player/shared/dock/peertube-dock-plugin'
import videojs from 'video.js'
import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
-import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models'
+import {
+ HTMLServerConfig,
+ ResultList,
+ ServerErrorCode,
+ VideoDetails,
+ VideoPlaylist,
+ VideoPlaylistElement,
+ VideoState
+} from '../../../../shared/models'
import { PeertubePlayerManager } from '../../assets/player'
import { TranslationsManager } from '../../assets/player/translations-manager'
-import { getParamString, logger, videoRequiresAuth } from '../../root-helpers'
+import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
import { PeerTubeEmbedApi } from './embed-api'
import {
AuthHTTP,
@@ -19,6 +27,7 @@ import {
VideoFetcher
} from './shared'
import { PlayerHTML } from './shared/player-html'
+import { PeerTubeServerError } from 'src/types'
export class PeerTubeEmbed {
player: videojs.Player
@@ -38,6 +47,8 @@ export class PeerTubeEmbed {
private readonly liveManager: LiveManager
private playlistTracker: PlaylistTracker
+ private videoPassword: string
+ private requiresPassword: boolean
constructor (videoWrapperId: string) {
logger.registerServerSending(window.location.origin)
@@ -50,6 +61,7 @@ export class PeerTubeEmbed {
this.playerHTML = new PlayerHTML(videoWrapperId)
this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
this.liveManager = new LiveManager(this.playerHTML)
+ this.requiresPassword = false
try {
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
@@ -176,11 +188,13 @@ export class PeerTubeEmbed {
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
try {
- const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid)
+ const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay })
} catch (err) {
- this.playerHTML.displayError(err.message, await this.translationsPromise)
+
+ if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
+ else this.playerHTML.displayError(err.message, await this.translationsPromise)
}
}
@@ -205,8 +219,8 @@ export class PeerTubeEmbed {
? await this.videoFetcher.loadLive(videoInfo)
: undefined
- const videoFileToken = videoRequiresAuth(videoInfo)
- ? await this.videoFetcher.loadVideoToken(videoInfo)
+ const videoFileToken = videoRequiresFileToken(videoInfo)
+ ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
: undefined
return { live, video: videoInfo, videoFileToken }
@@ -232,6 +246,8 @@ export class PeerTubeEmbed {
authorizationHeader: () => this.http.getHeaderTokenValue(),
videoFileToken: () => videoFileToken,
+ videoPassword: () => this.videoPassword,
+ requiresPassword: this.requiresPassword,
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
@@ -263,6 +279,7 @@ export class PeerTubeEmbed {
this.initializeApi()
this.playerHTML.removePlaceholder()
+ if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
if (this.isPlaylistEmbed()) {
await this.buildPlayerPlaylistUpnext()
@@ -401,6 +418,21 @@ export class PeerTubeEmbed {
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
}
+ private async handlePasswordError (err: PeerTubeServerError) {
+ let incorrectPassword: boolean = null
+ if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
+ else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true
+
+ if (incorrectPassword === null) return false
+
+ this.requiresPassword = true
+ this.videoPassword = await this.playerHTML.askVideoPassword({
+ incorrectPassword,
+ translations: await this.translationsPromise
+ })
+ return true
+ }
+
}
PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
index 95e3b029e..c1e9f7750 100644
--- a/client/src/standalone/videos/shared/auth-http.ts
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -18,10 +18,12 @@ export class AuthHTTP {
if (this.userOAuthTokens) this.setHeadersFromTokens()
}
- fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) {
- const refreshFetchOptions = optionalAuth
- ? { headers: this.headers }
- : {}
+ fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
+ let refreshFetchOptions: { headers?: Headers } = {}
+
+ if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
+
+ if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }
return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
}
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
index d93678c10..a0846d9d7 100644
--- a/client/src/standalone/videos/shared/player-html.ts
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -55,6 +55,58 @@ export class PlayerHTML {
this.wrapperElement.style.display = 'none'
}
+ async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise {
+ const { incorrectPassword, translations } = options
+ return new Promise((resolve) => {
+
+ this.removePlaceholder()
+ this.wrapperElement.style.display = 'none'
+
+ const translatedTitle = peertubeTranslate('This video is password protected', translations)
+ const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations)
+
+ document.title = translatedTitle
+
+ const videoPasswordBlock = document.getElementById('video-password-block')
+ videoPasswordBlock.style.display = 'flex'
+
+ const videoPasswordTitle = document.getElementById('video-password-title')
+ videoPasswordTitle.innerHTML = translatedTitle
+
+ const videoPasswordMessage = document.getElementById('video-password-content')
+ videoPasswordMessage.innerHTML = translatedMessage
+
+ if (incorrectPassword) {
+ const videoPasswordError = document.getElementById('video-password-error')
+ videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations)
+ videoPasswordError.style.transform = 'scale(1.2)'
+
+ setTimeout(() => {
+ videoPasswordError.style.transform = 'scale(1)'
+ }, 500)
+ }
+
+ const videoPasswordSubmitButton = document.getElementById('video-password-submit')
+ videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations)
+
+ const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement
+ videoPasswordInput.placeholder = peertubeTranslate('Password', translations)
+
+ const videoPasswordForm = document.getElementById('video-password-form')
+ videoPasswordForm.addEventListener('submit', (event) => {
+ event.preventDefault()
+ const videoPassword = videoPasswordInput.value
+ resolve(videoPassword)
+ })
+ })
+ }
+
+ removeVideoPasswordBlock () {
+ const videoPasswordBlock = document.getElementById('video-password-block')
+ videoPasswordBlock.style.display = 'none'
+ this.wrapperElement.style.display = 'block'
+ }
+
buildPlaceholder (video: VideoDetails) {
const placeholder = this.getPlaceholderElement()
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
index 43ae22a3b..587516410 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -18,7 +18,7 @@ import {
logger,
peertubeLocalStorage,
UserLocalStorageKeys,
- videoRequiresAuth
+ videoRequiresUserAuth
} from '../../../root-helpers'
import { PeerTubePlugin } from './peertube-plugin'
import { PlayerHTML } from './player-html'
@@ -162,6 +162,9 @@ export class PlayerManagerOptions {
authorizationHeader: () => string
videoFileToken: () => string
+ videoPassword: () => string
+ requiresPassword: boolean
+
serverConfig: HTMLServerConfig
autoplayFromPreviousVideo: boolean
@@ -178,6 +181,8 @@ export class PlayerManagerOptions {
captionsResponse,
autoplayFromPreviousVideo,
videoFileToken,
+ videoPassword,
+ requiresPassword,
translations,
forceAutoplay,
playlistTracker,
@@ -242,10 +247,13 @@ export class PlayerManagerOptions {
embedUrl: window.location.origin + video.embedPath,
embedTitle: video.name,
- requiresAuth: videoRequiresAuth(video),
+ requiresUserAuth: videoRequiresUserAuth(video),
authorizationHeader,
videoFileToken,
+ requiresPassword,
+ videoPassword,
+
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 cf6d12831..76ba0a3ed 100644
--- a/client/src/standalone/videos/shared/video-fetcher.ts
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -1,3 +1,4 @@
+import { PeerTubeServerError } from '../../../types'
import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
import { logger } from '../../../root-helpers'
import { AuthHTTP } from './auth-http'
@@ -8,8 +9,8 @@ export class VideoFetcher {
}
- async loadVideo (videoId: string) {
- const videoPromise = this.loadVideoInfo(videoId)
+ async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
+ const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
let videoResponse: Response
let isResponseOk: boolean
@@ -27,11 +28,14 @@ export class VideoFetcher {
if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
throw new Error('This video does not exist.')
}
-
+ if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) {
+ const res = await videoResponse.json()
+ throw new PeerTubeServerError(res.message, res.code)
+ }
throw new Error('We cannot fetch the video. Please try again later.')
}
- const captionsPromise = this.loadVideoCaptions(videoId)
+ const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
return { captionsPromise, videoResponse }
}
@@ -41,8 +45,8 @@ export class VideoFetcher {
.then(res => res.json() as Promise)
}
- loadVideoToken (video: VideoDetails) {
- return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' })
+ loadVideoToken (video: VideoDetails, videoPassword?: string) {
+ return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
.then(res => res.json() as Promise)
.then(token => token.files.token)
}
@@ -51,12 +55,12 @@ export class VideoFetcher {
return this.getVideoUrl(videoUUID) + '/views'
}
- private loadVideoInfo (videoId: string): Promise {
- return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
+ private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise {
+ return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
}
- private loadVideoCaptions (videoId: string): Promise {
- return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
+ private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise {
+ return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
}
private getVideoUrl (id: string) {
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index 5508515fd..60564496c 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -1,4 +1,5 @@
export * from './client-script.model'
+export * from './server-error.model'
export * from './job-state-client.type'
export * from './job-type-client.type'
export * from './link.type'
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts
new file mode 100644
index 000000000..4a57287fe
--- /dev/null
+++ b/client/src/types/server-error.model.ts
@@ -0,0 +1,11 @@
+import { ServerErrorCode } from '@shared/models/index'
+
+export class PeerTubeServerError extends Error {
+ serverCode: ServerErrorCode
+
+ constructor (message: string, serverCode: ServerErrorCode) {
+ super(message)
+ this.name = 'CustomError'
+ this.serverCode = serverCode
+ }
+}
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index 72136614c..d03d0fe83 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -69,7 +69,10 @@ const playerKeys = {
'{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
'Previous video': 'Previous video',
'Video page (new window)': 'Video page (new window)',
- 'Next video': 'Next video'
+ 'Next video': 'Next video',
+ 'This video is password protected': 'This video is password protected',
+ 'You need a password to watch this video.': 'You need a password to watch this video.',
+ 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password'
}
Object.assign(playerKeys, videojs)
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 6a50aaf4e..b8016140e 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
videoChannel: res.locals.videoChannel,
tags: body.tags || undefined,
user,
+ videoPasswords: body.videoPasswords,
videoImportAttributes: {
magnetUri,
torrentName,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index a34325e79..d0eecf812 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding'
import { updateRouter } from './update'
import { uploadRouter } from './upload'
import { viewRouter } from './view'
+import { videoPasswordRouter } from './passwords'
const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router()
@@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter)
videosRouter.use('/', filesRouter)
videosRouter.use('/', transcodingRouter)
videosRouter.use('/', tokenRouter)
+videosRouter.use('/', videoPasswordRouter)
videosRouter.get('/categories',
openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index de047d4ec..cf82c9791 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live'
import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
import { buildUUID, uuidToShort } from '@shared/extra-utils'
-import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models'
+import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database'
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
import { VideoModel } from '../../../models/video/video'
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
+import { VideoPasswordModel } from '@server/models/video/video-password'
const liveRouter = express.Router()
@@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
await federateVideoIfNeeded(videoCreated, true, t)
+ if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
+ await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
+ }
+
logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
return { videoCreated }
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts
new file mode 100644
index 000000000..d11cf5bcc
--- /dev/null
+++ b/server/controllers/api/videos/passwords.ts
@@ -0,0 +1,105 @@
+import express from 'express'
+
+import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
+import { getFormattedObjects } from '../../../helpers/utils'
+import {
+ asyncMiddleware,
+ asyncRetryTransactionMiddleware,
+ authenticate,
+ setDefaultPagination,
+ setDefaultSort
+} from '../../../middlewares'
+import {
+ listVideoPasswordValidator,
+ paginationValidator,
+ removeVideoPasswordValidator,
+ updateVideoPasswordListValidator,
+ videoPasswordsSortValidator
+} from '../../../middlewares/validators'
+import { VideoPasswordModel } from '@server/models/video/video-password'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { Transaction } from 'sequelize'
+import { getVideoWithAttributes } from '@server/helpers/video'
+
+const lTags = loggerTagsFactory('api', 'video')
+const videoPasswordRouter = express.Router()
+
+videoPasswordRouter.get('/:videoId/passwords',
+ authenticate,
+ paginationValidator,
+ videoPasswordsSortValidator,
+ setDefaultSort,
+ setDefaultPagination,
+ asyncMiddleware(listVideoPasswordValidator),
+ asyncMiddleware(listVideoPasswords)
+)
+
+videoPasswordRouter.put('/:videoId/passwords',
+ authenticate,
+ asyncMiddleware(updateVideoPasswordListValidator),
+ asyncMiddleware(updateVideoPasswordList)
+)
+
+videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
+ authenticate,
+ asyncMiddleware(removeVideoPasswordValidator),
+ asyncRetryTransactionMiddleware(removeVideoPassword)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ videoPasswordRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoPasswords (req: express.Request, res: express.Response) {
+ const options = {
+ videoId: res.locals.videoAll.id,
+ start: req.query.start,
+ count: req.query.count,
+ sort: req.query.sort
+ }
+
+ const resultList = await VideoPasswordModel.listPasswords(options)
+
+ return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function updateVideoPasswordList (req: express.Request, res: express.Response) {
+ const videoInstance = getVideoWithAttributes(res)
+ const videoId = videoInstance.id
+
+ const passwordArray = req.body.passwords as string[]
+
+ await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
+ await VideoPasswordModel.deleteAllPasswords(videoId, t)
+ await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
+ })
+
+ logger.info(
+ `Video passwords for video with name %s and uuid %s have been updated`,
+ videoInstance.name,
+ videoInstance.uuid,
+ lTags(videoInstance.uuid)
+ )
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function removeVideoPassword (req: express.Request, res: express.Response) {
+ const videoInstance = getVideoWithAttributes(res)
+ const password = res.locals.videoPassword
+
+ await VideoPasswordModel.deletePassword(password.id)
+ logger.info(
+ 'Password with id %d of video named %s and uuid %s has been deleted.',
+ password.id,
+ videoInstance.name,
+ videoInstance.uuid,
+ lTags(videoInstance.uuid)
+ )
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 22387c3e8..e961ffd9e 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -1,13 +1,14 @@
import express from 'express'
import { VideoTokensManager } from '@server/lib/video-tokens-manager'
-import { VideoToken } from '@shared/models'
-import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares'
+import { VideoPrivacy, VideoToken } from '@shared/models'
+import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
const tokenRouter = express.Router()
tokenRouter.post('/:id/token',
- authenticate,
+ optionalAuthenticate,
asyncMiddleware(videosCustomGetValidator('only-video')),
+ videoFileTokenValidator,
generateToken
)
@@ -22,12 +23,11 @@ export {
function generateToken (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
- const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
+ const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
+ ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
+ : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
return res.json({
- files: {
- token,
- expires
- }
+ files
} as VideoToken)
}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index ddab428d4..28ec2cf37 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,13 +2,12 @@ import express from 'express'
import { Transaction } from 'sequelize/types'
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
-import { VideoPathManager } from '@server/lib/video-path-manager'
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 { forceNumber } from '@shared/core-utils'
-import { HttpStatusCode, VideoUpdate } from '@shared/models'
+import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { createReqFiles } from '../../../helpers/express-utils'
@@ -20,6 +19,9 @@ 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'
+import { VideoPasswordModel } from '@server/models/video/video-password'
+import { exists } from '@server/helpers/custom-validators/misc'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
setVideoPrivacy(videoInstance, newPrivacy)
+ // Delete passwords if video is not anymore password protected
+ if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
+ await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
+ }
+
+ if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
+ await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
+ await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
+ }
+
// Unfederate the video if the new privacy is not compatible with federation
if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
await VideoModel.sendDelete(videoInstance, { transaction })
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 885ac8b81..073eb480f 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
import { VideoSourceModel } from '@server/models/video/video-source'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
import { uuidToShort } from '@shared/extra-utils'
-import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models'
+import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { createReqFiles } from '../../../helpers/express-utils'
import { logger, loggerTagsFactory } from '../../../helpers/logger'
@@ -33,6 +33,7 @@ import {
} from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video'
+import { VideoPasswordModel } from '@server/models/video/video-password'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
@@ -195,6 +196,10 @@ async function addVideo (options: {
transaction: t
})
+ if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
+ await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
+ }
+
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 5f75ec27c..91109217c 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,7 +1,7 @@
-import { UploadFilesForCheck } from 'express'
+import { Response, Request, UploadFilesForCheck } from 'express'
import { decode as magnetUriDecode } from 'magnet-uri'
import validator from 'validator'
-import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
+import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
import {
CONSTRAINTS_FIELDS,
MIMETYPES,
@@ -13,6 +13,7 @@ import {
VIDEO_STATES
} from '../../initializers/constants'
import { exists, isArray, isDateValid, isFileValid } from './misc'
+import { getVideoWithAttributes } from '@server/helpers/video'
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
@@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
return VIDEO_PRIVACIES[value] !== undefined
}
+function isVideoReplayPrivacyValid (value: number) {
+ return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
+}
+
function isScheduleVideoUpdatePrivacyValid (value: number) {
return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
}
@@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
return parsed && isVideoFileInfoHashValid(parsed.infoHash)
}
+function isPasswordValid (password: string) {
+ return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min &&
+ password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max
+}
+
+function isValidPasswordProtectedPrivacy (req: Request, res: Response) {
+ const fail = (message: string) => {
+ res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message
+ })
+ return false
+ }
+
+ let privacy: VideoPrivacy
+ const video = getVideoWithAttributes(res)
+
+ if (exists(req.body?.privacy)) privacy = req.body.privacy
+ else if (exists(video?.privacy)) privacy = video.privacy
+
+ if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true
+
+ if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.')
+
+ const passwords = req.body.videoPasswords || req.body.passwords
+
+ if (passwords.length === 0) return fail('At least one video password is required.')
+
+ if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.')
+
+ for (const password of passwords) {
+ if (typeof password !== 'string') {
+ return fail('Video password should be a string.')
+ }
+
+ if (!isPasswordValid(password)) {
+ return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.')
+ }
+ }
+
+ return true
+}
+
// ---------------------------------------------------------------------------
export {
@@ -164,9 +212,12 @@ export {
isVideoDurationValid,
isVideoTagValid,
isVideoPrivacyValid,
+ isVideoReplayPrivacyValid,
isVideoFileResolutionValid,
isVideoFileSizeValid,
isVideoImageValid,
isVideoSupportValid,
- isVideoFilterValid
+ isVideoFilterValid,
+ isPasswordValid,
+ isValidPasswordProtectedPrivacy
}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index a92fd22d6..e2f34fe16 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 780
+const LAST_MIGRATION_VERSION = 785
// ---------------------------------------------------------------------------
@@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = {
VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
VIDEO_COMMENTS: [ 'createdAt' ],
+ VIDEO_PASSWORDS: [ 'createdAt' ],
+
VIDEO_RATES: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
@@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = {
REASON: { min: 1, max: 5000 }, // Length
ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
PROGRESS: { min: 0, max: 100 } // Value
+ },
+ VIDEO_PASSWORD: {
+ LENGTH: { min: 2, max: 100 }
}
}
@@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
[VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private',
- [VideoPrivacy.INTERNAL]: 'Internal'
+ [VideoPrivacy.INTERNAL]: 'Internal',
+ [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
}
const VIDEO_STATES: { [ id in VideoState ]: string } = {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 14dd8c379..9e926c26c 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -56,6 +56,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
import { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/view/video-view'
import { CONFIG } from './config'
+import { VideoPasswordModel } from '@server/models/video/video-password'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) {
VideoJobInfoModel,
VideoChannelSyncModel,
UserRegistrationModel,
+ VideoPasswordModel,
RunnerRegistrationTokenModel,
RunnerModel,
RunnerJobModel
diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts
new file mode 100644
index 000000000..1d85f4489
--- /dev/null
+++ b/server/initializers/migrations/0785-video-password-protection.ts
@@ -0,0 +1,31 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+}): Promise {
+ {
+ const query = `
+ CREATE TABLE IF NOT EXISTS "videoPassword" (
+ "id" SERIAL,
+ "password" VARCHAR(255) NOT NULL,
+ "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
+ "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
+ PRIMARY KEY ("id")
+ );
+ `
+
+ await utils.sequelize.query(query, { transaction : utils.transaction })
+ }
+}
+
+function down (options) {
+ throw new Error('Not implemented.')
+}
+
+export {
+ up,
+ down
+}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 18b16bee1..be6df1792 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity'
import { getBiggestActorImage } from './actor-image'
import { Hooks } from './plugins/hooks'
import { ServerConfigManager } from './server-config-manager'
+import { isVideoInPrivateDirectory } from './video-privacy'
type Tags = {
ogType: string
@@ -106,7 +107,7 @@ class ClientHtml {
])
// Let Angular application handle errors
- if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) {
+ if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
res.status(HttpStatusCode.NOT_FOUND_404)
return html
}
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
index df67dc953..0ac667ba3 100644
--- a/server/lib/video-pre-import.ts
+++ b/server/lib/video-pre-import.ts
@@ -30,6 +30,7 @@ import {
import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
import { getLocalVideoActivityPubUrl } from './activitypub/url'
import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
+import { VideoPasswordModel } from '@server/models/video/video-password'
class YoutubeDlImportError extends Error {
code: YoutubeDlImportError.CODE
@@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
tags: string[]
videoImportAttributes: FilteredModelAttributes
user: MUser
+ videoPasswords?: string[]
}): Promise {
- const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters
+ const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
const videoImport = await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t }
@@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
+ if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
+ await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
+ }
+
await autoBlacklistVideoIfNeeded({
video: videoCreated,
user,
@@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
state: VideoImportState.PENDING,
userId: user.id,
videoChannelSyncId: channelSync?.id
- }
+ },
+ videoPasswords: importDataOverride.videoPasswords
})
// Get video subtitles
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts
index 41f9d62b3..39430ef1e 100644
--- a/server/lib/video-privacy.ts
+++ b/server/lib/video-privacy.ts
@@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { VideoPrivacy, VideoStorage } from '@shared/models'
import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
+const validPrivacySet = new Set([
+ VideoPrivacy.PRIVATE,
+ VideoPrivacy.INTERNAL,
+ VideoPrivacy.PASSWORD_PROTECTED
+])
+
function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
video.publishedAt = new Date()
@@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
video.privacy = newPrivacy
}
-function isVideoInPrivateDirectory (privacy: VideoPrivacy) {
- return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL
+function isVideoInPrivateDirectory (privacy) {
+ return validPrivacySet.has(privacy)
}
function isVideoInPublicDirectory (privacy: VideoPrivacy) {
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index 660533528..e28e55cf7 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -12,26 +12,34 @@ class VideoTokensManager {
private static instance: VideoTokensManager
- private readonly lruCache = new LRUCache({
+ private readonly lruCache = new LRUCache({
max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
ttl: LRU_CACHE.VIDEO_TOKENS.TTL
})
private constructor () {}
- create (options: {
+ createForAuthUser (options: {
user: MUserAccountUrl
videoUUID: string
}) {
- const token = buildUUID()
-
- const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
+ const { token, expires } = this.generateVideoToken()
this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
return { token, expires }
}
+ createForPasswordProtectedVideo (options: {
+ videoUUID: string
+ }) {
+ const { token, expires } = this.generateVideoToken()
+
+ this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
+
+ return { token, expires }
+ }
+
hasToken (options: {
token: string
videoUUID: string
@@ -54,6 +62,13 @@ class VideoTokensManager {
static get Instance () {
return this.instance || (this.instance = new this())
}
+
+ private generateVideoToken () {
+ const token = buildUUID()
+ const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
+
+ return { token, expires }
+ }
}
// ---------------------------------------------------------------------------
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts
index 0eefa2a8e..39a7b2998 100644
--- a/server/middlewares/auth.ts
+++ b/server/middlewares/auth.ts
@@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner'
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
import { logger } from '../helpers/logger'
import { handleOAuthAuthenticate } from '../lib/auth/oauth'
+import { ServerErrorCode } from '@shared/models'
function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
handleOAuthAuthenticate(req, res)
@@ -48,15 +49,23 @@ 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) {
+function authenticatePromise (options: {
+ req: express.Request
+ res: express.Response
+ errorMessage?: string
+ errorStatus?: HttpStatusCode
+ errorType?: ServerErrorCode
+}) {
+ const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
return new Promise(resolve => {
// Already authenticated? (or tried to)
if (res.locals.oauth?.token.User) return resolve()
if (res.locals.authenticated === false) {
return res.fail({
- status: HttpStatusCode.UNAUTHORIZED_401,
- message: 'Not authenticated'
+ status: errorStatus,
+ type: errorType,
+ message: errorMessage
})
}
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index de98cd442..e5cff2dda 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -10,4 +10,5 @@ export * from './video-comments'
export * from './video-imports'
export * from './video-ownerships'
export * from './video-playlists'
+export * from './video-passwords'
export * from './videos'
diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts
new file mode 100644
index 000000000..efcc95dc4
--- /dev/null
+++ b/server/middlewares/validators/shared/video-passwords.ts
@@ -0,0 +1,80 @@
+import express from 'express'
+import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models'
+import { forceNumber } from '@shared/core-utils'
+import { VideoPasswordModel } from '@server/models/video/video-password'
+import { header } from 'express-validator'
+import { getVideoWithAttributes } from '@server/helpers/video'
+
+function isValidVideoPasswordHeader () {
+ return header('x-peertube-video-password')
+ .optional()
+ .isString()
+}
+
+function checkVideoIsPasswordProtected (res: express.Response) {
+ const video = getVideoWithAttributes(res)
+ if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
+ res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: 'Video is not password protected'
+ })
+ return false
+ }
+
+ return true
+}
+
+async function doesVideoPasswordExist (idArg: number | string, res: express.Response) {
+ const video = getVideoWithAttributes(res)
+ const id = forceNumber(idArg)
+ const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
+
+ if (!videoPassword) {
+ res.fail({
+ status: HttpStatusCode.NOT_FOUND_404,
+ message: 'Video password not found'
+ })
+ return false
+ }
+
+ res.locals.videoPassword = videoPassword
+
+ return true
+}
+
+async function isVideoPasswordDeletable (res: express.Response) {
+ const user = res.locals.oauth.token.User
+ const userAccount = user.Account
+ const video = res.locals.videoAll
+
+ // Check if the user who did the request is able to delete the video passwords
+ if (
+ user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator
+ video.VideoChannel.accountId !== userAccount.id // Not the video owner
+ ) {
+ res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ message: 'Cannot remove passwords of another user\'s video'
+ })
+ return false
+ }
+
+ const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
+
+ if (passwordCount <= 1) {
+ res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: 'Cannot delete the last password of the protected video'
+ })
+ return false
+ }
+
+ return true
+}
+
+export {
+ isValidVideoPasswordHeader,
+ checkVideoIsPasswordProtected as isVideoPasswordProtected,
+ doesVideoPasswordExist,
+ isVideoPasswordDeletable
+}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index 0033a32ff..9a7497007 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -20,6 +20,8 @@ import {
MVideoWithRights
} from '@server/types/models'
import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
+import { VideoPasswordModel } from '@server/models/video/video-password'
+import { exists } from '@server/helpers/custom-validators/misc'
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: {
}) {
const { req, res, video, paramId } = options
- if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) {
- return checkCanSeeAuthVideo(req, res, video)
+ if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
+ return checkCanSeeUserAuthVideo({ req, res, video })
+ }
+
+ if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
+ return checkCanSeePasswordProtectedVideo({ req, res, video })
}
if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: {
throw new Error('Unknown video privacy when checking video right ' + video.url)
}
-async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) {
+async function checkCanSeeUserAuthVideo (options: {
+ req: Request
+ res: Response
+ video: MVideoId | MVideoWithRights
+}) {
+ const { req, res, video } = options
+
const fail = () => {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return false
}
- await authenticatePromise(req, res)
+ await authenticatePromise({ req, res })
const user = res.locals.oauth?.token.User
if (!user) return fail()
- const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId
- ? video as MVideoWithRights
- : await VideoModel.loadFull(video.id)
+ const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
const privacy = videoWithRights.privacy
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return true
}
- const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
-
if (videoWithRights.isBlacklisted()) {
- if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true
+ if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true
return fail()
}
if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
- if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true
+ if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
return fail()
}
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return fail()
}
+async function checkCanSeePasswordProtectedVideo (options: {
+ req: Request
+ res: Response
+ video: MVideo
+}) {
+ const { req, res, video } = options
+
+ const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
+
+ const videoPassword = req.header('x-peertube-video-password')
+
+ if (!exists(videoPassword)) {
+ const errorMessage = 'Please provide a password to access this password protected video'
+ const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
+
+ if (req.header('authorization')) {
+ await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType })
+ const user = res.locals.oauth?.token.User
+
+ if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
+ }
+
+ res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ type: errorType,
+ message: errorMessage
+ })
+ return false
+ }
+
+ if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
+
+ res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
+ message: 'Incorrect video password. Access to the video is denied.'
+ })
+
+ return false
+}
+
+function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) {
+ const isOwnedByUser = video.VideoChannel.Account.userId === user.id
+
+ return isOwnedByUser || user.hasRight(right)
+}
+
+async function getVideoWithRights (video: MVideoWithRights): Promise {
+ return video.VideoChannel?.Account?.userId
+ ? video
+ : VideoModel.loadFull(video.id)
+}
+
// ---------------------------------------------------------------------------
async function checkCanAccessVideoStaticFiles (options: {
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
}) {
const { video, req, res } = options
- if (res.locals.oauth?.token.User) {
+ if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) {
return checkCanSeeVideo(options)
}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 959f663ac..07d6cba82 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
+export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts
index 9c2d890ba..36a94080c 100644
--- a/server/middlewares/validators/static.ts
+++ b/server/middlewares/validators/static.ts
@@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file'
import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
import { HttpStatusCode } from '@shared/models'
-import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared'
+import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
type LRUValue = {
allowed: boolean
@@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache({
const ensureCanAccessVideoPrivateWebTorrentFiles = [
query('videoFileToken').optional().custom(exists),
+ isValidVideoPasswordHeader(),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
.optional()
.customSanitizer(isSafePeerTubeFilenameWithoutExtension),
+ isValidVideoPasswordHeader(),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU
}
function extractTokenOrDie (req: express.Request, res: express.Response) {
- const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken
+ const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
if (!token) {
return res.fail({
- message: 'Bearer token is missing in headers or video file token is missing in URL query parameters',
+ message: 'Video password header, video file token query parameter and bearer token are all missing', //
status: HttpStatusCode.FORBIDDEN_403
})
}
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index d225dfe45..0c824c314 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -12,6 +12,8 @@ export * from './video-shares'
export * from './video-source'
export * from './video-stats'
export * from './video-studio'
+export * from './video-token'
export * from './video-transcoding'
export * from './videos'
export * from './video-channel-sync'
+export * from './video-passwords'
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 72b2febc3..077a58d2e 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -10,7 +10,8 @@ import {
checkUserCanManageVideo,
doesVideoCaptionExist,
doesVideoExist,
- isValidVideoIdParam
+ isValidVideoIdParam,
+ isValidVideoPasswordHeader
} from '../shared'
const addVideoCaptionValidator = [
@@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
const listVideoCaptionsValidator = [
isValidVideoIdParam('videoId'),
+ isValidVideoPasswordHeader(),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 133feb7bd..70689b02e 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -14,7 +14,8 @@ import {
doesVideoCommentExist,
doesVideoCommentThreadExist,
doesVideoExist,
- isValidVideoIdParam
+ isValidVideoIdParam,
+ isValidVideoPasswordHeader
} from '../shared'
const listVideoCommentsValidator = [
@@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
const listVideoCommentThreadsValidator = [
isValidVideoIdParam('videoId'),
+ isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
param('threadId')
.custom(isIdValid),
+ isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
body('text')
.custom(isValidVideoCommentText),
+ isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
@@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
isValidVideoIdParam('videoId'),
param('commentId').custom(isIdValid),
+ isValidVideoPasswordHeader(),
body('text').custom(isValidVideoCommentText),
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index 72442aeb6..a1cb65b70 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
-import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
+import {
+ isValidPasswordProtectedPrivacy,
+ isVideoMagnetUriValid,
+ isVideoNameValid
+} from '../../../helpers/custom-validators/videos'
import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config'
@@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
+ body('videoPasswords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
@@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
+
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
cleanUpReqFiles(req)
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 2aff831a8..ec69a3011 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -17,7 +17,7 @@ import {
VideoState
} from '@shared/models'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
-import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos'
+import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos'
import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config'
@@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
- .custom(isVideoPrivacyValid),
+ .custom(isVideoReplayPrivacyValid),
body('permanentLive')
.optional()
@@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
+ body('videoPasswords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
+
if (CONFIG.LIVE.ENABLED !== true) {
cleanUpReqFiles(req)
@@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
- .custom(isVideoPrivacyValid),
+ .custom(isVideoReplayPrivacyValid),
body('latencyMode')
.optional()
diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts
new file mode 100644
index 000000000..200e496f6
--- /dev/null
+++ b/server/middlewares/validators/videos/video-passwords.ts
@@ -0,0 +1,77 @@
+import express from 'express'
+import {
+ areValidationErrors,
+ doesVideoExist,
+ isVideoPasswordProtected,
+ isValidVideoIdParam,
+ doesVideoPasswordExist,
+ isVideoPasswordDeletable,
+ checkUserCanManageVideo
+} from '../shared'
+import { body, param } from 'express-validator'
+import { isIdValid } from '@server/helpers/custom-validators/misc'
+import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos'
+import { UserRight } from '@shared/models'
+
+const listVideoPasswordValidator = [
+ isValidVideoIdParam('videoId'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ if (!await doesVideoExist(req.params.videoId, res)) return
+ if (!isVideoPasswordProtected(res)) return
+
+ // Check if the user who did the request is able to access video password list
+ const user = res.locals.oauth.token.User
+ if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return
+
+ return next()
+ }
+]
+
+const updateVideoPasswordListValidator = [
+ body('passwords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ if (!await doesVideoExist(req.params.videoId, res)) return
+ if (!isValidPasswordProtectedPrivacy(req, res)) return
+
+ // Check if the user who did the request is able to update video passwords
+ const user = res.locals.oauth.token.User
+ if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+ return next()
+ }
+]
+
+const removeVideoPasswordValidator = [
+ isValidVideoIdParam('videoId'),
+
+ param('passwordId')
+ .custom(isIdValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ if (!await doesVideoExist(req.params.videoId, res)) return
+ if (!isVideoPasswordProtected(res)) return
+ if (!await doesVideoPasswordExist(req.params.passwordId, res)) return
+ if (!await isVideoPasswordDeletable(res)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ listVideoPasswordValidator,
+ updateVideoPasswordListValidator,
+ removeVideoPasswordValidator
+}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index c631a16f8..95a5ba63a 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
}
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
- await authenticatePromise(req, res)
+ await authenticatePromise({ req, res })
const user = res.locals.oauth ? res.locals.oauth.token.User : null
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 275634d5b..c837b047b 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc'
import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
-import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
+import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared'
const videoUpdateRateValidator = [
isValidVideoIdParam('id'),
body('rating')
.custom(isVideoRatingTypeValid),
+ isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts
new file mode 100644
index 000000000..d4253e21d
--- /dev/null
+++ b/server/middlewares/validators/videos/video-token.ts
@@ -0,0 +1,24 @@
+import express from 'express'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { HttpStatusCode } from '@shared/models'
+import { exists } from '@server/helpers/custom-validators/misc'
+
+const videoFileTokenValidator = [
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ const video = res.locals.onlyVideo
+ if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
+ return res.fail({
+ status: HttpStatusCode.UNAUTHORIZED_401,
+ message: 'Not authenticated'
+ })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ videoFileTokenValidator
+}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 794e1d4f1..7f1f39b11 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../
import {
areVideoTagsValid,
isScheduleVideoUpdatePrivacyValid,
+ isValidPasswordProtectedPrivacy,
isVideoCategoryValid,
isVideoDescriptionValid,
isVideoFileMimeTypeValid,
@@ -55,7 +56,8 @@ import {
doesVideoChannelOfAccountExist,
doesVideoExist,
doesVideoFileOfVideoExist,
- isValidVideoIdParam
+ isValidVideoIdParam,
+ isValidVideoPasswordHeader
} from '../shared'
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
@@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
+ body('videoPasswords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
@@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
return cleanUpReqFiles(req)
}
+ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
+
try {
if (!videoFile.duration) await addDurationToVideo(videoFile)
} catch (err) {
@@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
+ body('videoPasswords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
header('x-upload-content-length')
.isNumeric()
@@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
const files = { videofile: [ videoFileMetadata ] }
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
+ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
+
// multer required unsetting the Content-Type, now we can set it for node-uploadx
req.headers['content-type'] = 'application/json; charset=utf-8'
// place previewfile in metadata so that uploadx saves it in .META
@@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
+ body('videoPasswords')
+ .optional()
+ .isArray()
+ .withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
+ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
+
const video = getVideoWithAttributes(res)
if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
return res.fail({ message: 'Cannot update privacy of a live that has already started' })
@@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
return [
isValidVideoIdParam('id'),
+ isValidVideoPasswordHeader(),
+
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, fetchType)) return
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
new file mode 100644
index 000000000..648366c3b
--- /dev/null
+++ b/server/models/video/video-password.ts
@@ -0,0 +1,137 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { VideoModel } from './video'
+import { AttributesOnly } from '@shared/typescript-utils'
+import { ResultList, VideoPassword } from '@shared/models'
+import { getSort, throwIfNotValid } from '../shared'
+import { FindOptions, Transaction } from 'sequelize'
+import { MVideoPassword } from '@server/types/models'
+import { isPasswordValid } from '@server/helpers/custom-validators/videos'
+import { pick } from '@shared/core-utils'
+
+@DefaultScope(() => ({
+ include: [
+ {
+ model: VideoModel.unscoped(),
+ required: true
+ }
+ ]
+}))
+@Table({
+ tableName: 'videoPassword',
+ indexes: [
+ {
+ fields: [ 'videoId', 'password' ],
+ unique: true
+ }
+ ]
+})
+export class VideoPasswordModel extends Model>> {
+
+ @AllowNull(false)
+ @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
+ @Column
+ password: string
+
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ Video: VideoModel
+
+ static async countByVideoId (videoId: number, t?: Transaction) {
+ const query: FindOptions = {
+ where: {
+ videoId
+ },
+ transaction: t
+ }
+
+ return VideoPasswordModel.count(query)
+ }
+
+ static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise {
+ const { id, videoId, t } = options
+ const query: FindOptions = {
+ where: {
+ id,
+ videoId
+ },
+ transaction: t
+ }
+
+ return VideoPasswordModel.findOne(query)
+ }
+
+ static async listPasswords (options: {
+ start: number
+ count: number
+ sort: string
+ videoId: number
+ }): Promise> {
+ const { start, count, sort, videoId } = options
+
+ const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
+ where: { videoId },
+ order: getSort(sort),
+ offset: start,
+ limit: count
+ })
+
+ return { total, data }
+ }
+
+ static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise {
+ for (const password of passwords) {
+ await VideoPasswordModel.create({
+ password,
+ videoId
+ }, { transaction })
+ }
+ }
+
+ static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
+ await VideoPasswordModel.destroy({
+ where: { videoId },
+ transaction
+ })
+ }
+
+ static async deletePassword (passwordId: number, transaction?: Transaction) {
+ await VideoPasswordModel.destroy({
+ where: { id: passwordId },
+ transaction
+ })
+ }
+
+ static async isACorrectPassword (options: {
+ videoId: number
+ password: string
+ }) {
+ const query = {
+ where: pick(options, [ 'videoId', 'password' ])
+ }
+ return VideoPasswordModel.findOne(query)
+ }
+
+ toFormattedJSON (): VideoPassword {
+ return {
+ id: this.id,
+ password: this.password,
+ videoId: this.videoId,
+ createdAt: this.createdAt,
+ updatedAt: this.updatedAt
+ }
+ }
+}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index b832f9768..61ae6b9fe 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model>> {
})
VideoCaptions: VideoCaptionModel[]
+ @HasMany(() => VideoPasswordModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ VideoPasswords: VideoPasswordModel[]
+
@HasOne(() => VideoJobInfoModel, {
foreignKey: {
name: 'videoId',
@@ -1918,7 +1928,7 @@ export class VideoModel extends Model>> {
// ---------------------------------------------------------------------------
- requiresAuth (options: {
+ requiresUserAuth (options: {
urlParamId: string
checkBlacklist: boolean
}) {
@@ -1936,11 +1946,11 @@ export class VideoModel extends Model>> {
if (checkBlacklist && this.VideoBlacklist) return true
- if (this.privacy !== VideoPrivacy.PUBLIC) {
- throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
+ if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
+ return false
}
- return false
+ throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
}
hasPrivateStaticPath () {
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 2dc735c23..406a96824 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -143,7 +143,7 @@ describe('Test video lives API validator', function () {
})
it('Should fail with a bad privacy for replay settings', async function () {
- const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } }
+ const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } }
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
})
@@ -472,7 +472,7 @@ describe('Test video lives API validator', function () {
})
it('Should fail with a bad privacy for replay settings', async function () {
- const fields = { saveReplay: true, replaySettings: { privacy: 5 } }
+ const fields = { saveReplay: true, replaySettings: { privacy: 999 } }
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts
new file mode 100644
index 000000000..4e936b5d2
--- /dev/null
+++ b/server/tests/api/check-params/video-passwords.ts
@@ -0,0 +1,609 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+import {
+ FIXTURE_URLS,
+ checkBadCountPagination,
+ checkBadSortPagination,
+ checkBadStartPagination,
+ checkUploadVideoParam
+} from '@server/tests/shared'
+import { root } from '@shared/core-utils'
+import {
+ HttpStatusCode,
+ PeerTubeProblemDocument,
+ ServerErrorCode,
+ VideoCreateResult,
+ VideoPrivacy
+} from '@shared/models'
+import {
+ cleanupTests,
+ createSingleServer,
+ makePostBodyRequest,
+ PeerTubeServer,
+ setAccessTokensToServers
+} from '@shared/server-commands'
+import { expect } from 'chai'
+import { join } from 'path'
+
+describe('Test video passwords validator', function () {
+ let path: string
+ let server: PeerTubeServer
+ let userAccessToken = ''
+ let video: VideoCreateResult
+ let channelId: number
+ let publicVideo: VideoCreateResult
+ let commentId: number
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(50000)
+
+ server = await createSingleServer(1)
+
+ await setAccessTokensToServers([ server ])
+
+ await server.config.updateCustomSubConfig({
+ newConfig: {
+ live: {
+ enabled: true,
+ latencySetting: {
+ enabled: false
+ },
+ allowReplay: false
+ },
+ import: {
+ videos: {
+ http:{
+ enabled: true
+ }
+ }
+ }
+ }
+ })
+
+ userAccessToken = await server.users.generateUserAndToken('user1')
+
+ {
+ const body = await server.users.getMyInfo()
+ channelId = body.videoChannels[0].id
+ }
+
+ {
+ video = await server.videos.quickUpload({
+ name: 'password protected video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ 'password1', 'password2' ]
+ })
+ }
+ path = '/api/v1/videos/'
+ })
+
+ async function checkVideoPasswordOptions (options: {
+ server: PeerTubeServer
+ token: string
+ videoPasswords: string[]
+ expectedStatus: HttpStatusCode
+ mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live'
+ }) {
+ const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options
+ const attaches = {
+ fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
+ }
+ const baseCorrectParams = {
+ name: 'my super name',
+ category: 5,
+ licence: 1,
+ language: 'pt',
+ nsfw: false,
+ commentsEnabled: true,
+ downloadEnabled: true,
+ waitTranscoding: true,
+ description: 'my super description',
+ support: 'my super support text',
+ tags: [ 'tag1', 'tag2' ],
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ channelId,
+ originallyPublishedAt: new Date().toISOString()
+ }
+ if (mode === 'uploadLegacy') {
+ const fields = { ...baseCorrectParams, videoPasswords }
+ return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy')
+ }
+
+ if (mode === 'uploadResumable') {
+ const fields = { ...baseCorrectParams, videoPasswords }
+ return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable')
+ }
+
+ if (mode === 'import') {
+ const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords }
+ return server.imports.importVideo({ attributes, expectedStatus })
+ }
+
+ if (mode === 'updateVideo') {
+ const attributes = { ...baseCorrectParams, videoPasswords }
+ return server.videos.update({ token, expectedStatus, id: video.id, attributes })
+ }
+
+ if (mode === 'updatePasswords') {
+ return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords })
+ }
+
+ if (mode === 'live') {
+ const fields = { ...baseCorrectParams, videoPasswords }
+
+ return server.live.create({ fields, expectedStatus })
+ }
+ }
+
+ function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
+
+ it('Should fail with a password protected privacy without providing a password', async function () {
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords: undefined,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ it('Should fail with a password protected privacy and an empty password list', async function () {
+ const videoPasswords = []
+
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ it('Should fail with a password protected privacy and a too short password', async function () {
+ const videoPasswords = [ 'p' ]
+
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ it('Should fail with a password protected privacy and a too long password', async function () {
+ const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ]
+
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ it('Should fail with a password protected privacy and an empty password', async function () {
+ const videoPasswords = [ '' ]
+
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ it('Should fail with a password protected privacy and duplicated passwords', async function () {
+ const videoPasswords = [ 'password', 'password' ]
+
+ await checkVideoPasswordOptions({
+ server,
+ token: server.accessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400,
+ mode
+ })
+ })
+
+ if (mode === 'updatePasswords') {
+ it('Should fail for an unauthenticated user', async function () {
+ const videoPasswords = [ 'password' ]
+ await checkVideoPasswordOptions({
+ server,
+ token: null,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
+ mode
+ })
+ })
+
+ it('Should fail for an unauthorized user', async function () {
+ const videoPasswords = [ 'password' ]
+ await checkVideoPasswordOptions({
+ server,
+ token: userAccessToken,
+ videoPasswords,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ mode
+ })
+ })
+ }
+
+ it('Should succeed with a password protected privacy and correct passwords', async function () {
+ const videoPasswords = [ 'password1', 'password2' ]
+ const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo'
+ ? HttpStatusCode.NO_CONTENT_204
+ : HttpStatusCode.OK_200
+
+ await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode })
+ })
+ }
+
+ describe('When adding or updating a video', function () {
+ describe('Resumable upload', function () {
+ validateVideoPasswordList('uploadResumable')
+ })
+
+ describe('Legacy upload', function () {
+ validateVideoPasswordList('uploadLegacy')
+ })
+
+ describe('When importing a video', function () {
+ validateVideoPasswordList('import')
+ })
+
+ describe('When updating a video', function () {
+ validateVideoPasswordList('updateVideo')
+ })
+
+ describe('When updating the password list of a video', function () {
+ validateVideoPasswordList('updatePasswords')
+ })
+
+ describe('When creating a live', function () {
+ validateVideoPasswordList('live')
+ })
+ })
+
+ async function checkVideoAccessOptions (options: {
+ server: PeerTubeServer
+ token?: string
+ videoPassword?: string
+ expectedStatus: HttpStatusCode
+ mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token'
+ }) {
+ const { server, token = null, videoPassword, expectedStatus, mode } = options
+
+ if (mode === 'get') {
+ return server.videos.get({ id: video.id, expectedStatus })
+ }
+
+ if (mode === 'getWithToken') {
+ return server.videos.getWithToken({
+ id: video.id,
+ token,
+ expectedStatus
+ })
+ }
+
+ if (mode === 'getWithPassword') {
+ return server.videos.getWithPassword({
+ id: video.id,
+ token,
+ expectedStatus,
+ password: videoPassword
+ })
+ }
+
+ if (mode === 'rate') {
+ return server.videos.rate({
+ id: video.id,
+ token,
+ expectedStatus,
+ rating: 'like',
+ videoPassword
+ })
+ }
+
+ if (mode === 'createThread') {
+ const fields = { text: 'super comment' }
+ const headers = videoPassword !== undefined && videoPassword !== null
+ ? { 'x-peertube-video-password': videoPassword }
+ : undefined
+ const body = await makePostBodyRequest({
+ url: server.url,
+ path: path + video.uuid + '/comment-threads',
+ token,
+ fields,
+ headers,
+ expectedStatus
+ })
+ return JSON.parse(body.text)
+ }
+
+ if (mode === 'replyThread') {
+ const fields = { text: 'super reply' }
+ const headers = videoPassword !== undefined && videoPassword !== null
+ ? { 'x-peertube-video-password': videoPassword }
+ : undefined
+ return makePostBodyRequest({
+ url: server.url,
+ path: path + video.uuid + '/comments/' + commentId,
+ token,
+ fields,
+ headers,
+ expectedStatus
+ })
+ }
+ if (mode === 'listThreads') {
+ return server.comments.listThreads({
+ videoId: video.id,
+ token,
+ expectedStatus,
+ videoPassword
+ })
+ }
+
+ if (mode === 'listCaptions') {
+ return server.captions.list({
+ videoId: video.id,
+ token,
+ expectedStatus,
+ videoPassword
+ })
+ }
+
+ if (mode === 'token') {
+ return server.videoToken.create({
+ videoId: video.id,
+ token,
+ expectedStatus,
+ videoPassword
+ })
+ }
+ }
+
+ function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') {
+ const serverCode = mode === 'providePassword'
+ ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD
+ : ServerErrorCode.INCORRECT_VIDEO_PASSWORD
+
+ const message = mode === 'providePassword'
+ ? 'Please provide a password to access this password protected video'
+ : 'Incorrect video password. Access to the video is denied.'
+
+ if (!error.code) {
+ error = JSON.parse(error.text)
+ }
+
+ expect(error.code).to.equal(serverCode)
+ expect(error.detail).to.equal(message)
+ expect(error.error).to.equal(message)
+
+ expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
+ }
+
+ function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') {
+ const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
+ let tokens: string[]
+ if (!requiresUserAuth) {
+ it('Should fail without providing a password for an unlogged user', async function () {
+ const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
+ const error = body as unknown as PeerTubeProblemDocument
+
+ checkVideoError(error, 'providePassword')
+ })
+ }
+
+ it('Should fail without providing a password for an unauthorised user', async function () {
+ const tmp = mode === 'get' ? 'getWithToken' : mode
+
+ const body = await checkVideoAccessOptions({
+ server,
+ token: userAccessToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ mode: tmp
+ })
+
+ const error = body as unknown as PeerTubeProblemDocument
+
+ checkVideoError(error, 'providePassword')
+ })
+
+ it('Should fail if a wrong password is entered', async function () {
+ const tmp = mode === 'get' ? 'getWithPassword' : mode
+ tokens = [ userAccessToken, server.accessToken ]
+
+ if (!requiresUserAuth) tokens.push(null)
+
+ for (const token of tokens) {
+ const body = await checkVideoAccessOptions({
+ server,
+ token,
+ videoPassword: 'toto',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ mode: tmp
+ })
+ const error = body as unknown as PeerTubeProblemDocument
+
+ checkVideoError(error, 'incorrectPassword')
+ }
+ })
+
+ it('Should fail if an empty password is entered', async function () {
+ const tmp = mode === 'get' ? 'getWithPassword' : mode
+
+ for (const token of tokens) {
+ const body = await checkVideoAccessOptions({
+ server,
+ token,
+ videoPassword: '',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ mode: tmp
+ })
+ const error = body as unknown as PeerTubeProblemDocument
+
+ checkVideoError(error, 'incorrectPassword')
+ }
+ })
+
+ it('Should fail if an inccorect password containing the correct password is entered', async function () {
+ const tmp = mode === 'get' ? 'getWithPassword' : mode
+
+ for (const token of tokens) {
+ const body = await checkVideoAccessOptions({
+ server,
+ token,
+ videoPassword: 'password11',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ mode: tmp
+ })
+ const error = body as unknown as PeerTubeProblemDocument
+
+ checkVideoError(error, 'incorrectPassword')
+ }
+ })
+
+ it('Should succeed without providing a password for an authorised user', async function () {
+ const tmp = mode === 'get' ? 'getWithToken' : mode
+ const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
+
+ const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp })
+
+ if (mode === 'createThread') commentId = body.comment.id
+ })
+
+ it('Should succeed using correct passwords', async function () {
+ const tmp = mode === 'get' ? 'getWithPassword' : mode
+ const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
+
+ for (const token of tokens) {
+ await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp })
+ await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp })
+ }
+ })
+ }
+
+ describe('When accessing password protected video', function () {
+
+ describe('For getting a password protected video', function () {
+ validateVideoAccess('get')
+ })
+
+ describe('For rating a video', function () {
+ validateVideoAccess('rate')
+ })
+
+ describe('For creating a thread', function () {
+ validateVideoAccess('createThread')
+ })
+
+ describe('For replying to a thread', function () {
+ validateVideoAccess('replyThread')
+ })
+
+ describe('For listing threads', function () {
+ validateVideoAccess('listThreads')
+ })
+
+ describe('For getting captions', function () {
+ validateVideoAccess('listCaptions')
+ })
+
+ describe('For creating video file token', function () {
+ validateVideoAccess('token')
+ })
+ })
+
+ describe('When listing passwords', function () {
+ it('Should fail with a bad start pagination', async function () {
+ await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
+ })
+
+ it('Should fail with a bad count pagination', async function () {
+ await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
+ })
+
+ it('Should fail with an incorrect sort', async function () {
+ await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
+ })
+
+ it('Should fail for unauthenticated user', async function () {
+ await server.videoPasswords.list({
+ token: null,
+ expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
+ videoId: video.id
+ })
+ })
+
+ it('Should fail for unauthorized user', async function () {
+ await server.videoPasswords.list({
+ token: userAccessToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ videoId: video.id
+ })
+ })
+
+ it('Should succeed with the correct parameters', async function () {
+ await server.videoPasswords.list({
+ token: server.accessToken,
+ expectedStatus: HttpStatusCode.OK_200,
+ videoId: video.id
+ })
+ })
+ })
+
+ describe('When deleting a password', async function () {
+ const passwords = (await server.videoPasswords.list({ videoId: video.id })).data
+
+ it('Should fail with wrong password id', async function () {
+ await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+ })
+
+ it('Should fail for unauthenticated user', async function () {
+ await server.videoPasswords.remove({
+ id: passwords[0].id,
+ token: null,
+ videoId: video.id,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail for unauthorized user', async function () {
+ await server.videoPasswords.remove({
+ id: passwords[0].id,
+ token: userAccessToken,
+ videoId: video.id,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail for non password protected video', async function () {
+ publicVideo = await server.videos.quickUpload({ name: 'public video' })
+ await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ })
+
+ it('Should fail for password not linked to correct video', async function () {
+ const video2 = await server.videos.quickUpload({
+ name: 'password protected video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ 'password1', 'password2' ]
+ })
+ await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+ })
+
+ it('Should succeed with correct parameter', async function () {
+ await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
+ })
+
+ it('Should fail for last password of a video', async function () {
+ await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ })
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts
index 7acb9d580..7cb3e84a2 100644
--- a/server/tests/api/check-params/video-token.ts
+++ b/server/tests/api/check-params/video-token.ts
@@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
describe('Test video tokens', function () {
let server: PeerTubeServer
- let videoId: string
+ let privateVideoId: string
+ let passwordProtectedVideoId: string
let userToken: string
+ const videoPassword = 'password'
+
// ---------------------------------------------------------------
before(async function () {
@@ -15,27 +18,50 @@ describe('Test video tokens', function () {
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
-
- const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
- videoId = uuid
-
+ {
+ const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
+ privateVideoId = uuid
+ }
+ {
+ const { uuid } = await server.videos.quickUpload({
+ name: 'password protected video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ videoPassword ]
+ })
+ passwordProtectedVideoId = 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 on private video for unauthenticated user', async function () {
+ await server.videoToken.create({ videoId: privateVideoId, 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 with incorrect password', async function () {
+ await server.videoToken.create({
+ videoId: passwordProtectedVideoId,
+ token: null,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ videoPassword: 'incorrectPassword'
+ })
+ })
+
it('Should not generate tokens of a non owned video', async function () {
- await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should generate token', async function () {
- await server.videoToken.create({ videoId })
+ await server.videoToken.create({ videoId: privateVideoId })
+ })
+
+ it('Should generate token on password protected video', async function () {
+ await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null })
+ await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken })
+ await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword })
})
after(async function () {
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index af9d681b2..2a7c3381d 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () {
describe('VOD', function () {
let privateVideoUUID: string
let publicVideoUUID: string
+ let passwordProtectedVideoUUID: string
let userPrivateVideoUUID: string
+ const correctPassword = 'my super password'
+ const correctPasswordHeader = { 'x-peertube-video-password': correctPassword }
+ const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' }
+
// ---------------------------------------------------------------------------
async function getSampleFileUrls (videoId: string) {
@@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
await checkPrivateVODFiles(privateVideoUUID)
})
+ it('Should upload a password protected video and have appropriate object storage ACL', async function () {
+ this.timeout(120000)
+
+ {
+ const { uuid } = await server.videos.quickUpload({
+ name: 'video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ correctPassword ]
+ })
+ passwordProtectedVideoUUID = uuid
+ }
+ await waitJobs([ server ])
+
+ await checkPrivateVODFiles(passwordProtectedVideoUUID)
+ })
+
it('Should upload a public video and have appropriate object storage ACL', async function () {
this.timeout(120000)
@@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () {
await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
})
+ it('Should not get files without appropriate password or appropriate OAuth token', async function () {
+ this.timeout(60000)
+
+ const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
+
+ await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await makeRawRequest({
+ url: webTorrentFile,
+ token: null,
+ headers: incorrectPasswordHeader,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({
+ url: webTorrentFile,
+ token: null,
+ headers: correctPasswordHeader,
+ expectedStatus: HttpStatusCode.OK_200
+ })
+
+ await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await makeRawRequest({
+ url: hlsFile,
+ token: null,
+ headers: incorrectPasswordHeader,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({
+ url: hlsFile,
+ token: null,
+ headers: correctPasswordHeader,
+ expectedStatus: HttpStatusCode.OK_200
+ })
+ })
+
it('Should not get HLS file of another video', async function () {
this.timeout(60000)
@@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () {
await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
})
- it('Should correctly check OAuth or video file token', async function () {
+ it('Should correctly check OAuth, video file token of private video', async function () {
this.timeout(60000)
const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
@@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () {
await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
+
+ }
+ })
+
+ it('Should correctly check OAuth, video file token or video password of password protected video', async function () {
+ this.timeout(60000)
+
+ const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
+ const goodVideoFileToken = await server.videoToken.getVideoFileToken({
+ videoId: passwordProtectedVideoUUID,
+ videoPassword: correctPassword
+ })
+
+ const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
+
+ for (const url of [ hlsFile, webTorrentFile ]) {
+ await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
+
+ await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
+
+ await makeRawRequest({
+ url,
+ headers: incorrectPasswordHeader,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 })
}
})
@@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () {
let permanentLiveId: string
let permanentLive: LiveVideo
+ let passwordProtectedLiveId: string
+ let passwordProtectedLive: LiveVideo
+
+ const correctPassword = 'my super password'
+
let unrelatedFileToken: string
// ---------------------------------------------------------------------------
- async function checkLiveFiles (live: LiveVideo, liveId: string) {
+ async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: 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 video = videoPassword
+ ? await server.videos.getWithPassword({ id: liveId, password: videoPassword })
+ : await server.videos.getWithToken({ id: liveId })
+
+ const fileToken = videoPassword
+ ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword })
+ : await server.videoToken.getVideoFileToken({ videoId: video.uuid })
const hls = video.streamingPlaylists[0]
@@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () {
await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
-
+ if (videoPassword) {
+ await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, 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 })
+ if (videoPassword) {
+ await makeRawRequest({
+ url,
+ headers: { 'x-peertube-video-password': 'incorrectPassword' },
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ }
}
await stopFfmpeg(ffmpegCommand)
@@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
permanentLiveId = video.uuid
permanentLive = live
}
+
+ {
+ const { video, live } = await server.live.quickCreate({
+ saveReplay: false,
+ permanentLive: false,
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ correctPassword ]
+ })
+ passwordProtectedLiveId = video.uuid
+ passwordProtectedLive = live
+ }
})
it('Should create a private normal live and have a private static path', async function () {
@@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () {
await checkLiveFiles(permanentLive, permanentLiveId)
})
+ it('Should create a password protected live and have a private static path', async function () {
+ this.timeout(240000)
+
+ await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword)
+ })
+
it('Should reinject video file token in permanent live', async function () {
this.timeout(240000)
diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts
new file mode 100644
index 000000000..e01a93a4d
--- /dev/null
+++ b/server/tests/api/videos/video-passwords.ts
@@ -0,0 +1,97 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import {
+ cleanupTests,
+ createSingleServer,
+ VideoPasswordsCommand,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ setDefaultAccountAvatar,
+ setDefaultChannelAvatar
+} from '@shared/server-commands'
+import { VideoPrivacy } from '@shared/models'
+
+describe('Test video passwords', function () {
+ let server: PeerTubeServer
+ let videoUUID: string
+
+ let userAccessTokenServer1: string
+
+ let videoPasswords: string[] = []
+ let command: VideoPasswordsCommand
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await createSingleServer(1)
+
+ await setAccessTokensToServers([ server ])
+
+ for (let i = 0; i < 10; i++) {
+ videoPasswords.push(`password ${i + 1}`)
+ }
+ const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } })
+ videoUUID = uuid
+
+ await setDefaultChannelAvatar(server)
+ await setDefaultAccountAvatar(server)
+
+ userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
+ await setDefaultChannelAvatar(server, 'user1_channel')
+ await setDefaultAccountAvatar(server, userAccessTokenServer1)
+
+ command = server.videoPasswords
+ })
+
+ it('Should list video passwords', async function () {
+ const body = await command.list({ videoId: videoUUID })
+
+ expect(body.total).to.equal(10)
+ expect(body.data).to.be.an('array')
+ expect(body.data).to.have.lengthOf(10)
+ })
+
+ it('Should filter passwords on this video', async function () {
+ const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' })
+
+ expect(body.total).to.equal(10)
+ expect(body.data).to.be.an('array')
+ expect(body.data).to.have.lengthOf(2)
+ expect(body.data[0].password).to.equal('password 4')
+ expect(body.data[1].password).to.equal('password 5')
+ })
+
+ it('Should update password for this video', async function () {
+ videoPasswords = [ 'my super new password 1', 'my super new password 2' ]
+
+ await command.updateAll({ videoId: videoUUID, passwords: videoPasswords })
+ const body = await command.list({ videoId: videoUUID })
+ expect(body.total).to.equal(2)
+ expect(body.data).to.be.an('array')
+ expect(body.data).to.have.lengthOf(2)
+ expect(body.data[0].password).to.equal('my super new password 2')
+ expect(body.data[1].password).to.equal('my super new password 1')
+ })
+
+ it('Should delete one password', async function () {
+ {
+ const body = await command.list({ videoId: videoUUID })
+ expect(body.total).to.equal(2)
+ expect(body.data).to.be.an('array')
+ expect(body.data).to.have.lengthOf(2)
+ await command.remove({ id: body.data[0].id, videoId: videoUUID })
+ }
+ {
+ const body = await command.list({ videoId: videoUUID })
+
+ expect(body.total).to.equal(1)
+ expect(body.data).to.be.an('array')
+ expect(body.data).to.have.lengthOf(1)
+ }
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index d9c5bdf16..9277b49f4 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -474,7 +474,7 @@ describe('Test video playlists', function () {
await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
})
- it('Should get unlisted plyaylist using uuid or shortUUID', async function () {
+ it('Should get unlisted playlist using uuid or shortUUID', async function () {
await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
})
@@ -686,7 +686,7 @@ describe('Test video playlists', function () {
await waitJobs(servers)
})
- it('Should update the element type if the video is private', async function () {
+ it('Should update the element type if the video is private/password protected', async function () {
this.timeout(20000)
const name = 'video 89'
@@ -702,6 +702,19 @@ describe('Test video playlists', function () {
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
}
+ {
+ await servers[0].videos.update({
+ id: video1,
+ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
+ })
+ await waitJobs(servers)
+
+ await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+ await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
+ await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
+ await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
+ }
+
{
await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
await waitJobs(servers)
diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts
index 542848533..ec4c697db 100644
--- a/server/tests/api/videos/video-static-file-privacy.ts
+++ b/server/tests/api/videos/video-static-file-privacy.ts
@@ -90,7 +90,7 @@ describe('Test video static file privacy', function () {
}
}
- it('Should upload a private/internal video and have a private static path', async function () {
+ it('Should upload a private/internal/password protected video and have a private static path', async function () {
this.timeout(120000)
for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
@@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
await checkPrivateFiles(uuid)
}
+
+ const { uuid } = await server.videos.quickUpload({
+ name: 'video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ 'my super password' ]
+ })
+ await waitJobs([ server ])
+
+ await checkPrivateFiles(uuid)
})
it('Should upload a public video and update it as private/internal to have a private static path', async function () {
@@ -185,8 +194,9 @@ describe('Test video static file privacy', function () {
expectedStatus: HttpStatusCode
token: string
videoFileToken: string
+ videoPassword?: string
}) {
- const { id, expectedStatus, token, videoFileToken } = options
+ const { id, expectedStatus, token, videoFileToken, videoPassword } = options
const video = await server.videos.getWithToken({ id })
@@ -196,6 +206,12 @@ describe('Test video static file privacy', function () {
await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
+
+ if (videoPassword) {
+ const headers = { 'x-peertube-video-password': videoPassword }
+ await makeRawRequest({ url: file.fileUrl, headers, expectedStatus })
+ await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus })
+ }
}
const hls = video.streamingPlaylists[0]
@@ -204,6 +220,12 @@ describe('Test video static file privacy', function () {
await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
+
+ if (videoPassword) {
+ const headers = { 'x-peertube-video-password': videoPassword }
+ await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus })
+ await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus })
+ }
}
before(async function () {
@@ -216,13 +238,53 @@ describe('Test video static file privacy', function () {
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 })
+ const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
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 () {
+ it('Should not be able to access password protected video files without OAuth token, file token and password', async function () {
+ this.timeout(120000)
+ const videoPassword = 'my super password'
+
+ const { uuid } = await server.videos.quickUpload({
+ name: 'password protected video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ videoPassword ]
+ })
+ await waitJobs([ server ])
+
+ await checkVideoFiles({
+ id: uuid,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ token: null,
+ videoFileToken: null,
+ videoPassword: null
+ })
+ })
+
+ it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () {
+ this.timeout(120000)
+ const videoPassword = 'my super password'
+
+ const { uuid } = await server.videos.quickUpload({
+ name: 'password protected video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ videoPassword ]
+ })
+ await waitJobs([ server ])
+
+ await checkVideoFiles({
+ id: uuid,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403,
+ token: userToken,
+ videoFileToken: unrelatedFileToken,
+ videoPassword: 'incorrectPassword'
+ })
+ })
+
+ it('Should not be able to access an private 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 })
@@ -247,6 +309,23 @@ describe('Test video static file privacy', function () {
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
})
+ it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () {
+ this.timeout(120000)
+ const videoPassword = 'my super password'
+
+ const { uuid } = await server.videos.quickUpload({
+ name: 'video',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ videoPassword ]
+ })
+
+ const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword })
+
+ await waitJobs([ server ])
+
+ await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword })
+ })
+
it('Should reinject video file token', async function () {
this.timeout(120000)
@@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
let permanentLiveId: string
let permanentLive: LiveVideo
+ let passwordProtectedLiveId: string
+ let passwordProtectedLive: LiveVideo
+
+ const correctPassword = 'my super password'
+
let unrelatedFileToken: string
- async function checkLiveFiles (live: LiveVideo, liveId: string) {
+ async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) {
+ const { live, liveId, videoPassword } = options
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]
@@ -314,6 +400,16 @@ describe('Test video static file privacy', function () {
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 })
+
+ if (videoPassword) {
+ await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
+ await makeRawRequest({
+ url,
+ headers: { 'x-peertube-video-password': 'incorrectPassword' },
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ }
+
}
await stopFfmpeg(ffmpegCommand)
@@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
permanentLiveId = video.uuid
permanentLive = live
}
+
+ {
+ const { video, live } = await server.live.quickCreate({
+ saveReplay: false,
+ permanentLive: false,
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ correctPassword ]
+ })
+ passwordProtectedLiveId = video.uuid
+ passwordProtectedLive = live
+ }
})
it('Should create a private normal live and have a private static path', async function () {
this.timeout(240000)
- await checkLiveFiles(normalLive, normalLiveId)
+ await checkLiveFiles({ live: normalLive, liveId: normalLiveId })
})
it('Should create a private permanent live and have a private static path', async function () {
this.timeout(240000)
- await checkLiveFiles(permanentLive, permanentLiveId)
+ await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId })
+ })
+
+ it('Should create a password protected live and have a private static path', async function () {
+ this.timeout(240000)
+
+ await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword })
})
it('Should reinject video file token on permanent live', async function () {
diff --git a/server/tests/client.ts b/server/tests/client.ts
index e84251561..68f3a1d14 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -56,6 +56,7 @@ describe('Test a client controllers', function () {
let privateVideoId: string
let internalVideoId: string
let unlistedVideoId: string
+ let passwordProtectedVideoId: string
let playlistIds: (string | number)[] = []
@@ -92,7 +93,12 @@ describe('Test a client controllers', function () {
{
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
- ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }))
+ ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
+ ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
+ name: 'password protected',
+ privacy: VideoPrivacy.PASSWORD_PROTECTED,
+ videoPasswords: [ 'password' ]
+ }))
}
// Playlist
@@ -502,9 +508,9 @@ describe('Test a client controllers', function () {
}
})
- it('Should not display internal/private video', async function () {
+ it('Should not display internal/private/password protected video', async function () {
for (const basePath of watchVideoBasePaths) {
- for (const id of [ privateVideoId, internalVideoId ]) {
+ for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
const res = await makeGetRequest({
url: servers[0].url,
path: basePath + id,
@@ -514,6 +520,7 @@ describe('Test a client controllers', function () {
expect(res.text).to.not.contain('internal')
expect(res.text).to.not.contain('private')
+ expect(res.text).to.not.contain('password protected')
}
}
})
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 8433c873e..83a85be58 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -99,6 +99,13 @@ describe('Test syndication feeds', () => {
await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
}
+ {
+ const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
+ const { id } = await servers[0].videos.upload({ attributes })
+
+ await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
+ }
+
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
await waitJobs([ ...servers, serverHLSOnly ])
@@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
describe('Video comments feed', function () {
- it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () {
+ it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 510b9f94e..9c1be9bd1 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -18,6 +18,7 @@ import {
MVideoId,
MVideoImmutable,
MVideoLiveFormattable,
+ MVideoPassword,
MVideoPlaylistFull,
MVideoPlaylistFullSummary
} from '@server/types/models'
@@ -165,6 +166,8 @@ declare module 'express' {
videoCommentFull?: MCommentOwnerVideoReply
videoCommentThread?: MComment
+ videoPassword?: MVideoPassword
+
follow?: MActorFollowActorsDefault
subscription?: MActorFollowActorsDefaultSubscription
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 6e45fcc79..0ac032290 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -16,6 +16,7 @@ export * from './video-import'
export * from './video-live-replay-setting'
export * from './video-live-session'
export * from './video-live'
+export * from './video-password'
export * from './video-playlist'
export * from './video-playlist-element'
export * from './video-rate'
diff --git a/server/types/models/video/video-password.ts b/server/types/models/video/video-password.ts
new file mode 100644
index 000000000..313cc3e0c
--- /dev/null
+++ b/server/types/models/video/video-password.ts
@@ -0,0 +1,3 @@
+import { VideoPasswordModel } from '@server/models/video/video-password'
+
+export type MVideoPassword = Omit
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts
index 58ae7baad..8021e56bb 100644
--- a/server/types/models/video/video.ts
+++ b/server/types/models/video/video.ts
@@ -32,7 +32,7 @@ type Use = PickWith
export type MVideo =
Omit
+ 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'>
// ############################################################################
@@ -46,7 +46,7 @@ export type MVideoFeed = Pick
// ############################################################################
-// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists
+// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords
// "With" to not confuse with the VideoFile model
export type MVideoWithFile =
diff --git a/shared/core-utils/videos/common.ts b/shared/core-utils/videos/common.ts
index 2c6efdb7f..0431edaaf 100644
--- a/shared/core-utils/videos/common.ts
+++ b/shared/core-utils/videos/common.ts
@@ -3,7 +3,7 @@ import { VideoPrivacy } from '../../models/videos/video-privacy.enum'
import { VideoDetails } from '../../models/videos/video.model'
function getAllPrivacies () {
- return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ]
+ return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ]
}
function getAllFiles (video: Partial>) {
diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts
index 2b093380c..77d1e1d3f 100644
--- a/shared/models/server/server-error-code.enum.ts
+++ b/shared/models/server/server-error-code.enum.ts
@@ -49,7 +49,10 @@ export const enum ServerErrorCode {
RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state',
RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state',
- UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token'
+ UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token',
+
+ VIDEO_REQUIRES_PASSWORD = 'video_requires_password',
+ INCORRECT_VIDEO_PASSWORD = 'incorrect_video_password'
}
/**
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 4c1790228..80be1854b 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -39,3 +39,4 @@ export * from './video-update.model'
export * from './video-view.model'
export * from './video.model'
export * from './video-create-result.model'
+export * from './video-password.model'
diff --git a/shared/models/videos/video-create.model.ts b/shared/models/videos/video-create.model.ts
index 732d508d1..7a34b5afe 100644
--- a/shared/models/videos/video-create.model.ts
+++ b/shared/models/videos/video-create.model.ts
@@ -18,6 +18,7 @@ export interface VideoCreate {
privacy: VideoPrivacy
scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string
+ videoPasswords?: string[]
thumbnailfile?: Blob | string
previewfile?: Blob | string
diff --git a/shared/models/videos/video-password.model.ts b/shared/models/videos/video-password.model.ts
new file mode 100644
index 000000000..c0280b9b9
--- /dev/null
+++ b/shared/models/videos/video-password.model.ts
@@ -0,0 +1,7 @@
+export interface VideoPassword {
+ id: number
+ password: string
+ videoId: number
+ createdAt: Date | string
+ updatedAt: Date | string
+}
diff --git a/shared/models/videos/video-privacy.enum.ts b/shared/models/videos/video-privacy.enum.ts
index 39fd0529f..12e1d196f 100644
--- a/shared/models/videos/video-privacy.enum.ts
+++ b/shared/models/videos/video-privacy.enum.ts
@@ -2,5 +2,6 @@ export const enum VideoPrivacy {
PUBLIC = 1,
UNLISTED = 2,
PRIVATE = 3,
- INTERNAL = 4
+ INTERNAL = 4,
+ PASSWORD_PROTECTED = 5
}
diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts
index 86653b959..43537b5af 100644
--- a/shared/models/videos/video-update.model.ts
+++ b/shared/models/videos/video-update.model.ts
@@ -19,6 +19,7 @@ export interface VideoUpdate {
previewfile?: Blob
scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string
+ videoPasswords?: string[]
pluginData?: any
}
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index e3f1817f1..8227017eb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -29,6 +29,7 @@ function makeRawRequest (options: {
range?: string
query?: { [ id: string ]: string }
method?: 'GET' | 'POST'
+ headers?: { [ name: string ]: string }
}) {
const { host, protocol, pathname } = new URL(options.url)
@@ -37,7 +38,7 @@ function makeRawRequest (options: {
path: pathname,
contentType: undefined,
- ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ])
+ ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ])
}
if (options.method === 'POST') {
@@ -132,6 +133,7 @@ function makePutBodyRequest (options: {
token?: string
fields: { [ fieldName: string ]: any }
expectedStatus?: HttpStatusCode
+ headers?: { [name: string]: string }
}) {
const req = request(options.url).put(options.path)
.send(options.fields)
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index 70f7a3ee2..0911e22b0 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -32,6 +32,7 @@ import {
HistoryCommand,
ImportsCommand,
LiveCommand,
+ VideoPasswordsCommand,
PlaylistsCommand,
ServicesCommand,
StreamingPlaylistsCommand,
@@ -146,6 +147,7 @@ export class PeerTubeServer {
twoFactor?: TwoFactorCommand
videoToken?: VideoTokenCommand
registrations?: RegistrationsCommand
+ videoPasswords?: VideoPasswordsCommand
runners?: RunnersCommand
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -437,5 +439,6 @@ export class PeerTubeServer {
this.runners = new RunnersCommand(this)
this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
this.runnerJobs = new RunnerJobsCommand(this)
+ this.videoPasswords = new VideoPasswordsCommand(this)
}
}
diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts
index ca4ffada9..463acc26b 100644
--- a/shared/server-commands/shared/abstract-command.ts
+++ b/shared/server-commands/shared/abstract-command.ts
@@ -101,25 +101,29 @@ abstract class AbstractCommand {
protected putBodyRequest (options: InternalCommonCommandOptions & {
fields?: { [ fieldName: string ]: any }
+ headers?: { [name: string]: string }
}) {
- const { fields } = options
+ const { fields, headers } = options
return makePutBodyRequest({
...this.buildCommonRequestOptions(options),
- fields
+ fields,
+ headers
})
}
protected postBodyRequest (options: InternalCommonCommandOptions & {
fields?: { [ fieldName: string ]: any }
+ headers?: { [name: string]: string }
}) {
- const { fields } = options
+ const { fields, headers } = options
return makePostBodyRequest({
...this.buildCommonRequestOptions(options),
- fields
+ fields,
+ headers
})
}
@@ -206,6 +210,12 @@ abstract class AbstractCommand {
return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus
}
+
+ protected buildVideoPasswordHeader (videoPassword: string) {
+ return videoPassword !== undefined && videoPassword !== null
+ ? { 'x-peertube-video-password': videoPassword }
+ : undefined
+ }
}
export {
diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts
index 62bf9c5e6..a26fcb57d 100644
--- a/shared/server-commands/videos/captions-command.ts
+++ b/shared/server-commands/videos/captions-command.ts
@@ -34,14 +34,16 @@ export class CaptionsCommand extends AbstractCommand {
list (options: OverrideCommandOptions & {
videoId: string | number
+ videoPassword?: string
}) {
- const { videoId } = options
+ const { videoId, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/captions'
return this.getRequestBody>({
...options,
path,
+ headers: this.buildVideoPasswordHeader(videoPassword),
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts
index 154ec0c24..0dab1b66a 100644
--- a/shared/server-commands/videos/comments-command.ts
+++ b/shared/server-commands/videos/comments-command.ts
@@ -36,11 +36,12 @@ export class CommentsCommand extends AbstractCommand {
listThreads (options: OverrideCommandOptions & {
videoId: number | string
+ videoPassword?: string
start?: number
count?: number
sort?: string
}) {
- const { start, count, sort, videoId } = options
+ const { start, count, sort, videoId, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/comment-threads'
return this.getRequestBody({
@@ -48,6 +49,7 @@ export class CommentsCommand extends AbstractCommand {
path,
query: { start, count, sort },
+ headers: this.buildVideoPasswordHeader(videoPassword),
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
@@ -72,8 +74,9 @@ export class CommentsCommand extends AbstractCommand {
async createThread (options: OverrideCommandOptions & {
videoId: number | string
text: string
+ videoPassword?: string
}) {
- const { videoId, text } = options
+ const { videoId, text, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/comment-threads'
const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -81,6 +84,7 @@ export class CommentsCommand extends AbstractCommand {
path,
fields: { text },
+ headers: this.buildVideoPasswordHeader(videoPassword),
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
}))
@@ -95,8 +99,9 @@ export class CommentsCommand extends AbstractCommand {
videoId: number | string
toCommentId: number
text: string
+ videoPassword?: string
}) {
- const { videoId, toCommentId, text } = options
+ const { videoId, toCommentId, text, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId
const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -104,6 +109,7 @@ export class CommentsCommand extends AbstractCommand {
path,
fields: { text },
+ headers: this.buildVideoPasswordHeader(videoPassword),
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
}))
diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts
index c17f6ef20..da36b5b6b 100644
--- a/shared/server-commands/videos/index.ts
+++ b/shared/server-commands/videos/index.ts
@@ -17,3 +17,4 @@ export * from './video-studio-command'
export * from './video-token-command'
export * from './views-command'
export * from './videos-command'
+export * from './video-passwords-command'
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
index 44d625970..6006d9fe9 100644
--- a/shared/server-commands/videos/live-command.ts
+++ b/shared/server-commands/videos/live-command.ts
@@ -120,8 +120,13 @@ export class LiveCommand extends AbstractCommand {
saveReplay: boolean
permanentLive: boolean
privacy?: VideoPrivacy
+ videoPasswords?: string[]
}) {
- const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC } = options
+ const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options
+
+ const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED
+ ? { privacy: VideoPrivacy.PRIVATE }
+ : { privacy }
const { uuid } = await this.create({
...options,
@@ -130,9 +135,10 @@ export class LiveCommand extends AbstractCommand {
name: 'live',
permanentLive,
saveReplay,
- replaySettings: { privacy },
+ replaySettings,
channelId: this.server.store.channel.id,
- privacy
+ privacy,
+ videoPasswords
}
})
diff --git a/shared/server-commands/videos/video-passwords-command.ts b/shared/server-commands/videos/video-passwords-command.ts
new file mode 100644
index 000000000..bf10335b4
--- /dev/null
+++ b/shared/server-commands/videos/video-passwords-command.ts
@@ -0,0 +1,55 @@
+import { HttpStatusCode, ResultList, VideoPassword } from '@shared/models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+export class VideoPasswordsCommand extends AbstractCommand {
+
+ list (options: OverrideCommandOptions & {
+ videoId: number | string
+ start?: number
+ count?: number
+ sort?: string
+ }) {
+ const { start, count, sort, videoId } = options
+ const path = '/api/v1/videos/' + videoId + '/passwords'
+
+ return this.getRequestBody>({
+ ...options,
+
+ path,
+ query: { start, count, sort },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.OK_200
+ })
+ }
+
+ updateAll (options: OverrideCommandOptions & {
+ videoId: number | string
+ passwords: string[]
+ }) {
+ const { videoId, passwords } = options
+ const path = `/api/v1/videos/${videoId}/passwords`
+
+ return this.putBodyRequest({
+ ...options,
+ path,
+ fields: { passwords },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+
+ remove (options: OverrideCommandOptions & {
+ id: number
+ videoId: number | string
+ }) {
+ const { id, videoId } = options
+ const path = `/api/v1/videos/${videoId}/passwords/${id}`
+
+ return this.deleteRequest({
+ ...options,
+
+ path,
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+}
diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts
index 0531bee65..c4ed29a8c 100644
--- a/shared/server-commands/videos/video-token-command.ts
+++ b/shared/server-commands/videos/video-token-command.ts
@@ -8,12 +8,14 @@ export class VideoTokenCommand extends AbstractCommand {
create (options: OverrideCommandOptions & {
videoId: number | string
+ videoPassword?: string
}) {
- const { videoId } = options
+ const { videoId, videoPassword } = options
const path = '/api/v1/videos/' + videoId + '/token'
return unwrapBody(this.postBodyRequest({
...options,
+ headers: this.buildVideoPasswordHeader(videoPassword),
path,
implicitToken: true,
@@ -23,6 +25,7 @@ export class VideoTokenCommand extends AbstractCommand {
async getVideoFileToken (options: OverrideCommandOptions & {
videoId: number | string
+ videoPassword?: string
}) {
const { files } = await this.create(options)
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index b5df9c325..93ca623e1 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -111,8 +111,9 @@ export class VideosCommand extends AbstractCommand {
rate (options: OverrideCommandOptions & {
id: number | string
rating: UserVideoRateType
+ videoPassword?: string
}) {
- const { id, rating } = options
+ const { id, rating, videoPassword } = options
const path = '/api/v1/videos/' + id + '/rate'
return this.putBodyRequest({
@@ -120,6 +121,7 @@ export class VideosCommand extends AbstractCommand {
path,
fields: { rating },
+ headers: this.buildVideoPasswordHeader(videoPassword),
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
@@ -151,6 +153,23 @@ export class VideosCommand extends AbstractCommand {
})
}
+ getWithPassword (options: OverrideCommandOptions & {
+ id: number | string
+ password?: string
+ }) {
+ const path = '/api/v1/videos/' + options.id
+
+ return this.getRequestBody({
+ ...options,
+ headers:{
+ 'x-peertube-video-password': options.password
+ },
+ path,
+ implicitToken: false,
+ defaultExpectedStatus: HttpStatusCode.OK_200
+ })
+ }
+
getSource (options: OverrideCommandOptions & {
id: number | string
}) {
@@ -608,11 +627,13 @@ export class VideosCommand extends AbstractCommand {
nsfw?: boolean
privacy?: VideoPrivacy
fixture?: string
+ videoPasswords?: string[]
}) {
const attributes: VideoEdit = { name: options.name }
if (options.nsfw) attributes.nsfw = options.nsfw
if (options.privacy) attributes.privacy = options.privacy
if (options.fixture) attributes.fixture = options.fixture
+ if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords
return this.upload({ ...options, attributes })
}
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index cd50e86a6..ff94f802b 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -300,6 +300,8 @@ tags:
- name: Runner Registration Token
description: |
Manage runner registration token
+ - name: Video Passwords
+ description: Operation on video passwords
x-tagGroups:
- name: Static endpoints
@@ -337,6 +339,7 @@ x-tagGroups:
- Video Transcoding
- Live Videos
- Channels Sync
+ - Video Passwords
- name: Search
tags:
- Search
@@ -2359,6 +2362,7 @@ paths:
- OAuth2: []
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -2578,6 +2582,8 @@ paths:
format: date-time
scheduleUpdate:
$ref: '#/components/schemas/VideoScheduledUpdate'
+ videoPasswords:
+ $ref: '#/components/schemas/AddVideoPasswords'
encoding:
thumbnailfile:
contentType: image/jpeg
@@ -2590,6 +2596,7 @@ paths:
- Video
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -2597,6 +2604,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/VideoDetails'
+ '403':
+ description: provide a correct password to access this password protected video
delete:
summary: Delete a video
operationId: delVideo
@@ -2618,6 +2627,7 @@ paths:
- Video
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -3267,6 +3277,7 @@ paths:
- Live Videos
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -3665,6 +3676,7 @@ paths:
- Video Captions
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -3728,6 +3740,70 @@ paths:
'404':
description: video or language or caption for that language not found
+ /api/v1/videos/{id}/passwords:
+ get:
+ summary: List video passwords
+ security:
+ - OAuth2:
+ - user
+ tags:
+ - Video Passwords
+ parameters:
+ - $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/start'
+ - $ref: '#/components/parameters/count'
+ - $ref: '#/components/parameters/sort'
+ responses:
+ '204':
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VideoPasswordList'
+ '400':
+ description: video is not password protected
+ put:
+ summary: Update video passwords
+ security:
+ - OAuth2:
+ - user
+ tags:
+ - Video Passwords
+ parameters:
+ - $ref: '#/components/parameters/idOrUUID'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ passwords:
+ $ref: '#/components/schemas/AddVideoPasswords'
+ responses:
+ '204':
+ description: successful operation
+ '400':
+ description: video is not password protected
+
+ /api/v1/videos/{id}/passwords/{videoPasswordId}:
+ delete:
+ summary: Delete a video password
+ security:
+ - OAuth2:
+ - user
+ tags:
+ - Video Passwords
+ parameters:
+ - $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordId'
+ responses:
+ '204':
+ description: successful operation
+ '403':
+ description: cannot delete the last password of the protected video
+ '400':
+ description: video is not password protected
+
/api/v1/video-channels:
get:
summary: List video channels
@@ -4554,6 +4630,7 @@ paths:
- $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/commentsSort'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -4600,6 +4677,7 @@ paths:
parameters:
- $ref: '#/components/parameters/idOrUUID'
- $ref: '#/components/parameters/threadId'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -4618,6 +4696,7 @@ paths:
parameters:
- $ref: '#/components/parameters/idOrUUID'
- $ref: '#/components/parameters/commentId'
+ - $ref: '#/components/parameters/videoPasswordHeader'
responses:
'200':
description: successful operation
@@ -4668,6 +4747,7 @@ paths:
- Video Rates
parameters:
- $ref: '#/components/parameters/idOrUUID'
+ - $ref: '#/components/parameters/videoPasswordHeader'
requestBody:
content:
application/json:
@@ -6525,7 +6605,20 @@ components:
required: true
schema:
$ref: '#/components/schemas/UUIDv4'
-
+ videoPasswordId:
+ name: videoPasswordId
+ in: path
+ required: true
+ description: The video password id
+ schema:
+ $ref: '#/components/schemas/id'
+ videoPasswordHeader:
+ name: x-peertube-video-password
+ description: Required on password protected video
+ in: header
+ required: false
+ schema:
+ type: string
securitySchemes:
OAuth2:
description: |
@@ -8228,6 +8321,8 @@ components:
description: Video preview file
type: string
format: binary
+ videoPasswords:
+ $ref: '#/components/schemas/AddVideoPasswords'
required:
- channelId
- name
@@ -9616,6 +9711,29 @@ components:
privatePayload:
type: object
+ VideoPassword:
+ properties:
+ id:
+ $ref: '#/components/schemas/id'
+ password:
+ type: string
+ minLength: 2
+ videoId:
+ $ref: '#/components/schemas/id'
+ VideoPasswordList:
+ properties:
+ total:
+ type: integer
+ example: 1
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/VideoPassword'
+ AddVideoPasswords:
+ type: array
+ items:
+ $ref: "#/components/schemas/VideoPassword/properties/password"
+ uniqueItems: true
callbacks:
searchIndex:
'https://search.example.org/api/v1/search/videos':