Feature/password protected videos (#5836)

* Add server endpoints

* Refactoring test suites

* Update server and add openapi documentation

* fix compliation and tests

* upload/import password protected video on client

* add server error code

* Add video password to update resolver

* add custom message when sharing pw protected video

* improve confirm component

* Add new alert in component

* Add ability to watch protected video on client

* Cannot have password protected replay privacy

* Add migration

* Add tests

* update after review

* Update check params tests

* Add live videos test

* Add more filter test

* Update static file privacy test

* Update object storage tests

* Add test on feeds

* Add missing word

* Fix tests

* Fix tests on live videos

* add embed support on password protected videos

* fix style

* Correcting data leaks

* Unable to add password protected privacy on replay

* Updated code based on review comments

* fix validator and command

* Updated code based on review comments
This commit is contained in:
Wicklow 2023-06-29 07:48:55 +00:00 committed by GitHub
parent ae22c59f14
commit 40346ead2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 2631 additions and 251 deletions

View file

@ -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
}

View file

@ -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 })

View file

@ -120,7 +120,12 @@
</div>
</div>
<div *ngIf="schedulePublicationEnabled" class="form-group">
<div *ngIf="passwordProtectionSelected" class="form-group">
<label i18n for="videoPassword">Password</label>
<my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
</div>
<div *ngIf="schedulePublicationSelected" class="form-group">
<label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
<p-calendar
id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
@ -287,7 +292,7 @@
<div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
<label i18n for="replayPrivacy">Privacy of the new replay</label>
<my-select-options
labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy"
labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
></my-select-options>
</div>

View file

@ -14,6 +14,7 @@ import {
VIDEO_LICENCE_VALIDATOR,
VIDEO_NAME_VALIDATOR,
VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
VIDEO_PASSWORD_VALIDATOR,
VIDEO_PRIVACY_VALIDATOR,
VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
VIDEO_SUPPORT_VALIDATOR,
@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
// So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
videoPrivacies: VideoConstant<VideoPrivacy>[] = []
videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = []
replayPrivacies: VideoConstant<VideoPrivacy> [] = []
videoCategories: VideoConstant<number>[] = []
videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoLanguages[] = []
@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
pluginDataFormGroup: FormGroup
schedulePublicationEnabled = false
schedulePublicationSelected = false
passwordProtectionSelected = false
calendarLocale: any = {}
minScheduledDate = new Date()
@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
const obj: { [ id: string ]: BuildFormValidator } = {
name: VIDEO_NAME_VALIDATOR,
privacy: VIDEO_PRIVACY_VALIDATOR,
videoPassword: VIDEO_PASSWORD_VALIDATOR,
channelId: VIDEO_CHANNEL_VALIDATOR,
nsfw: null,
commentsEnabled: null,
@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.serverService.getVideoPrivacies()
.subscribe(privacies => {
this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
this.videoPrivacies = videoPrivacies
this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED)
// Can't schedule publication if private privacy is not available (could be deleted by a plugin)
const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
.subscribe(
newPrivacyId => {
this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
// Value changed
const scheduleControl = this.form.get('schedulePublicationAt')
const waitTranscodingControl = this.form.get('waitTranscoding')
if (this.schedulePublicationEnabled) {
if (this.schedulePublicationSelected) {
scheduleControl.setValidators([ Validators.required ])
waitTranscodingControl.disable()
@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.firstPatchDone = true
this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED
const videoPasswordControl = this.form.get('videoPassword')
if (this.passwordProtectionSelected) {
videoPasswordControl.setValidators([ Validators.required ])
} else {
videoPasswordControl.clearValidators()
}
videoPasswordControl.updateValueAndValidity()
}
)
}

View file

@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.buildForm({})
const { videoData } = this.route.snapshot.data
const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData
const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
this.videoDetails = video
this.videoEdit = new VideoEdit(this.videoDetails)
this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions

View file

@ -4,8 +4,9 @@ import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot } from '@angular/router'
import { AuthService } from '@app/core'
import { listUserChannelsForSelect } from '@app/helpers'
import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { VideoPrivacy } from '@shared/models/videos'
@Injectable()
export class VideoUpdateResolver {
@ -13,7 +14,8 @@ export class VideoUpdateResolver {
private videoService: VideoService,
private liveVideoService: LiveVideoService,
private authService: AuthService,
private videoCaptionService: VideoCaptionService
private videoCaptionService: VideoCaptionService,
private videoPasswordService: VideoPasswordService
) {
}
@ -21,11 +23,11 @@ export class VideoUpdateResolver {
const uuid: string = route.params['uuid']
return this.videoService.getVideo({ videoId: uuid })
.pipe(
switchMap(video => forkJoin(this.buildVideoObservables(video))),
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) =>
({ video, videoChannels, videoCaptions, videoSource, liveVideo }))
)
.pipe(
switchMap(video => forkJoin(this.buildVideoObservables(video))),
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
)
}
private buildVideoObservables (video: VideoDetails) {
@ -46,6 +48,10 @@ export class VideoUpdateResolver {
video.isLive
? this.liveVideoService.getVideoLive(video.id)
: of(undefined),
video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
: of(undefined)
]
}

View file

@ -1,7 +1,7 @@
<div class="video-actions-rates">
<div class="video-actions justify-content-end">
<my-video-rate
[video]="video" [isUserLoggedIn]="isUserLoggedIn"
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
(rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
></my-video-rate>
@ -20,7 +20,7 @@
<div
class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
*ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)"
*ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
[ngbTooltip]="tooltipSaveToPlaylist"
placement="bottom auto"
>
@ -43,7 +43,7 @@
<span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
</button>
<my-video-download #videoDownloadModal></my-video-download>
<my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
</ng-container>
<ng-container *ngIf="isUserLoggedIn">

View file

@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal'
import { SupportModalComponent } from '@app/shared/shared-support-modal'
import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { UserVideoRateType, VideoCaption } from '@shared/models/videos'
import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
@Component({
selector: 'my-action-buttons',
@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
@Input() video: VideoDetails
@Input() videoPassword: string
@Input() videoCaptions: VideoCaption[]
@Input() playlist: VideoPlaylist
@Input() isUserLoggedIn: boolean
@Input() isUserOwner: boolean
@Input() currentTime: number
@Input() currentPlaylistPosition: number
@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
private setVideoLikesBarTooltipText () {
this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
}
isVideoAddableToPlaylist () {
const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
if (!this.isUserLoggedIn) return false
if (isPasswordProtected) return this.isUserOwner
return true
}
}

View file

@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models'
})
export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
@Input() video: VideoDetails
@Input() videoPassword: string
@Input() isUserLoggedIn: boolean
@Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
}
private setRating (nextRating: UserVideoRateType) {
const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = {
const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = {
like: this.videoService.setVideoLike,
dislike: this.videoService.setVideoDislike,
none: this.videoService.unsetVideoLike
}
ratingMethods[nextRating].call(this.videoService, this.video.uuid)
ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
.subscribe({
next: () => {
// Update the video like attribute

View file

@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models'
export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
@Input() user: User
@Input() video: Video
@Input() videoPassword: string
@Input() parentComment?: VideoComment
@Input() parentComments?: VideoComment[]
@Input() focusOnInit = false
@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
private addCommentReply (commentCreate: VideoCommentCreate) {
return this.videoCommentService
.addCommentReply(this.video.uuid, this.parentComment.id, commentCreate)
.addCommentReply({
videoId: this.video.uuid,
inReplyToCommentId: this.parentComment.id,
comment: commentCreate,
videoPassword: this.videoPassword
})
}
private addCommentThread (commentCreate: VideoCommentCreate) {
return this.videoCommentService
.addCommentThread(this.video.uuid, commentCreate)
.addCommentThread(this.video.uuid, commentCreate, this.videoPassword)
}
private initTextValue () {

View file

@ -62,6 +62,7 @@
*ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
[user]="user"
[video]="video"
[videoPassword]="videoPassword"
[parentComment]="comment"
[parentComments]="newParentComments"
[focusOnInit]="true"
@ -75,6 +76,7 @@
<my-video-comment
[comment]="commentChild.comment"
[video]="video"
[videoPassword]="videoPassword"
[inReplyToCommentId]="inReplyToCommentId"
[commentTree]="commentChild"
[parentComments]="newParentComments"

View file

@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
@ViewChild('commentReportModal') commentReportModal: CommentReportComponent
@Input() video: Video
@Input() videoPassword: string
@Input() comment: VideoComment
@Input() parentComments: VideoComment[] = []
@Input() commentTree: VideoCommentThreadTree

View file

@ -20,6 +20,7 @@
<ng-template [ngIf]="video.commentsEnabled === true">
<my-video-comment-add
[video]="video"
[videoPassword]="videoPassword"
[user]="user"
(commentCreated)="onCommentThreadCreated($event)"
[textValue]="commentThreadRedraftValue"
@ -34,6 +35,7 @@
*ngIf="highlightedThread"
[comment]="highlightedThread"
[video]="video"
[videoPassword]="videoPassword"
[inReplyToCommentId]="inReplyToCommentId"
[commentTree]="threadComments[highlightedThread.id]"
[highlightedComment]="true"
@ -53,6 +55,7 @@
*ngIf="!highlightedThread || comment.id !== highlightedThread.id"
[comment]="comment"
[video]="video"
[videoPassword]="videoPassword"
[inReplyToCommentId]="inReplyToCommentId"
[commentTree]="threadComments[comment.id]"
[firstInThread]="i + 1 !== comments.length"

View file

@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
@Input() video: VideoDetails
@Input() videoPassword: string
@Input() user: User
@Output() timestampClicked = new EventEmitter<number>()
@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
const params = {
videoId: this.video.uuid,
threadId: commentId
threadId: commentId,
videoPassword: this.videoPassword
}
const obs = this.hooks.wrapObsFun(
@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
loadMoreThreads () {
const params = {
videoId: this.video.uuid,
videoPassword: this.videoPassword,
componentPagination: this.componentPagination,
sort: this.sort
}

View file

@ -42,3 +42,7 @@
<div class="blocked-label" i18n>This video is blocked.</div>
{{ video.blacklistedReason }}
</div>
<div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)">
This video is password protected.
</div>

View file

@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core'
import { AuthUser } from '@app/core'
import { VideoDetails } from '@app/shared/shared-main'
import { VideoState } from '@shared/models'
import { VideoPrivacy, VideoState } from '@shared/models'
@Component({
selector: 'my-video-alert',
@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
styleUrls: [ './video-alert.component.scss' ]
})
export class VideoAlertComponent {
@Input() user: AuthUser
@Input() video: VideoDetails
@Input() noPlaylistVideoFound: boolean
@ -46,4 +48,8 @@ export class VideoAlertComponent {
isLiveEnded () {
return this.video?.state.id === VideoState.LIVE_ENDED
}
isVideoPasswordProtected () {
return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
}

View file

@ -19,7 +19,7 @@
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
</div>
<my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
<my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
<!-- Video information -->
<div *ngIf="video" class="margin-content video-bottom">
@ -51,8 +51,8 @@
</div>
<my-action-buttons
[video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist"
[currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
[playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
></my-action-buttons>
</div>
</div>
@ -92,6 +92,7 @@
<my-video-comments
class="border-top"
[video]="video"
[videoPassword]="videoPassword"
[user]="user"
(timestampClicked)="handleTimestampClicked($event)"
></my-video-comments>

View file

@ -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,

View file

@ -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 })))

View file

@ -12,10 +12,12 @@
<div *ngIf="inputLabel" class="form-group mt-3">
<label for="confirmInput">{{ inputLabel }}</label>
<input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
<input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" />
<my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
<my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text>
</div>
<div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
</div>
<div class="modal-footer inputs">

View file

@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
inputValue = ''
confirmButtonText = ''
errorMessage = ''
isPasswordInput = false
private openedModal: NgbModalRef
@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit {
this.inputValue = ''
this.confirmButtonText = ''
this.isPasswordInput = false
this.errorMessage = ''
const { type, title, message, confirmButtonText } = payload
const { type, title, message, confirmButtonText, errorMessage } = payload
this.title = title
@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit {
} else if (type === 'confirm-password') {
this.inputLabel = $localize`Confirm your password`
this.isPasswordInput = true
this.errorMessage = errorMessage
}
this.confirmButtonText = confirmButtonText || $localize`Confirm`
@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit {
return this.expectedInputValue !== this.inputValue
}
hasError () {
return this.errorMessage
}
showModal () {
this.inputValue = ''

View file

@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = {
}
}
export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
MESSAGES: {
minLength: $localize`A password should be at least 2 characters long.`,
maxLength: $localize`A password should be shorter than 100 characters long.`,
required: $localize`A password is required for password protected video.`
}
}
export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ ],
MESSAGES: {}

View file

@ -52,6 +52,7 @@ import {
VideoFileTokenService,
VideoImportService,
VideoOwnershipService,
VideoPasswordService,
VideoResolver,
VideoService
} from './video'
@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel'
VideoChannelService,
VideoPasswordService,
CustomPageService,
ActorRedirectGuard

View file

@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor, ServerService } from '@app/core'
import { objectToFormData, sortBy } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main/video'
import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
import { peertubeTranslate } from '@shared/core-utils/i18n'
import { ResultList, VideoCaption } from '@shared/models'
import { environment } from '../../../../environments/environment'
@ -18,8 +18,10 @@ export class VideoCaptionService {
private restExtractor: RestExtractor
) {}
listCaptions (videoId: string): Observable<ResultList<VideoCaption>> {
return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`)
listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> {
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers })
.pipe(
switchMap(captionsResult => {
return this.serverService.getServerLocale()

View file

@ -5,6 +5,7 @@ export * from './video-edit.model'
export * from './video-file-token.service'
export * from './video-import.service'
export * from './video-ownership.service'
export * from './video-password.service'
export * from './video.model'
export * from './video.resolver'
export * from './video.service'

View file

@ -1,5 +1,5 @@
import { getAbsoluteAPIUrl } from '@app/helpers'
import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
import { VideoDetails } from './video-details.model'
import { objectKeysTyped } from '@shared/core-utils'
@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate {
waitTranscoding: boolean
channelId: number
privacy: VideoPrivacy
videoPassword?: string
support: string
thumbnailfile?: any
previewfile?: any
@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate {
pluginData?: any
constructor (video?: VideoDetails) {
constructor (video?: VideoDetails, videoPassword?: VideoPassword) {
if (!video) return
this.id = video.id
@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate {
: null
this.pluginData = video.pluginData
if (videoPassword) this.videoPassword = videoPassword.password
}
patch (values: { [ id: string ]: any }) {
@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate {
waitTranscoding: this.waitTranscoding,
channelId: this.channelId,
privacy: this.privacy,
videoPassword: this.videoPassword,
originallyPublishedAt: this.originallyPublishedAt
}

View file

@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoToken } from '@shared/models'
import { VideoService } from './video.service'
import { VideoPasswordService } from './video-password.service'
@Injectable()
export class VideoFileTokenService {
@ -15,16 +16,18 @@ export class VideoFileTokenService {
private restExtractor: RestExtractor
) {}
getVideoFileToken (videoUUID: string) {
getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
const existing = this.store.get(videoUUID)
if (existing) return of(existing)
return this.createVideoFileToken(videoUUID)
return this.createVideoFileToken(videoUUID, videoPassword)
.pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
}
private createVideoFileToken (videoUUID: string) {
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {})
private createVideoFileToken (videoUUID: string, videoPassword?: string) {
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
.pipe(
map(({ files }) => files),
catchError(err => this.restExtractor.handleError(err))

View file

@ -0,0 +1,29 @@
import { ResultList, VideoPassword } from '@shared/models'
import { Injectable } from '@angular/core'
import { catchError, switchMap } from 'rxjs'
import { HttpClient, HttpHeaders } from '@angular/common/http'
import { RestExtractor } from '@app/core'
import { VideoService } from './video.service'
@Injectable()
export class VideoPasswordService {
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) {}
static buildVideoPasswordHeader (videoPassword: string) {
return videoPassword
? new HttpHeaders().set('x-peertube-video-password', videoPassword)
: undefined
}
getVideoPasswords (options: { videoUUID: string }) {
return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`)
.pipe(
switchMap(res => res.data),
catchError(err => this.restExtractor.handleError(err))
)
}
}

View file

@ -281,6 +281,13 @@ export class Video implements VideoServerModel {
return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
}
canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) {
return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
user &&
this.isLocal === true &&
(this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
}
getExactNumberOfViews () {
if (this.isLive) {
return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)

View file

@ -33,6 +33,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel'
import { VideoDetails } from './video-details.model'
import { VideoEdit } from './video-edit.model'
import { Video } from './video.model'
import { VideoPasswordService } from './video-password.service'
export type CommonVideoParams = {
videoPagination?: ComponentPaginationLight
@ -69,16 +70,17 @@ export class VideoService {
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
}
getVideo (options: { videoId: string }): Observable<VideoDetails> {
return this.serverService.getServerLocale()
.pipe(
switchMap(translations => {
return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`)
.pipe(map(videoHash => ({ videoHash, translations })))
}),
map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
catchError(err => this.restExtractor.handleError(err))
)
getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
return this.serverService.getServerLocale().pipe(
switchMap(translations => {
return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers })
.pipe(map(videoHash => ({ videoHash, translations })))
}),
map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
catchError(err => this.restExtractor.handleError(err))
)
}
updateVideo (video: VideoEdit) {
@ -99,6 +101,9 @@ export class VideoService {
description,
channelId: video.channelId,
privacy: video.privacy,
videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
? [ video.videoPassword ]
: undefined,
tags: video.tags,
nsfw: video.nsfw,
waitTranscoding: video.waitTranscoding,
@ -353,16 +358,16 @@ export class VideoService {
)
}
setVideoLike (id: string) {
return this.setVideoRate(id, 'like')
setVideoLike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'like', videoPassword)
}
setVideoDislike (id: string) {
return this.setVideoRate(id, 'dislike')
setVideoDislike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'dislike', videoPassword)
}
unsetVideoLike (id: string) {
return this.setVideoRate(id, 'none')
unsetVideoLike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'none', videoPassword)
}
getUserVideoRating (id: string) {
@ -394,7 +399,8 @@ export class VideoService {
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
[VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
[VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
[VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`
[VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`,
[VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`
}
const videoPrivacies = serverPrivacies.map(p => {
@ -412,7 +418,13 @@ export class VideoService {
}
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ]
// We do not add a password as this requires additional configuration.
const order = [
VideoPrivacy.PRIVATE,
VideoPrivacy.INTERNAL,
VideoPrivacy.UNLISTED,
VideoPrivacy.PUBLIC
]
for (const privacy of order) {
if (serverPrivacies.find(p => p.id === privacy)) {
@ -499,14 +511,15 @@ export class VideoService {
}
}
private setVideoRate (id: string, rateType: UserVideoRateType) {
private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
const body: UserVideoRateUpdate = {
rating: rateType
}
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp
.put(url, body)
.put(url, body, { headers })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View file

@ -107,6 +107,10 @@
</a>
</div>
<div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning">
This video is password protected, please note that recipients will require the corresponding password to access the content.
</div>
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
<ng-container ngbNavItem="url">

View file

@ -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,

View file

@ -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<ThreadsResultList<VideoComment>> {
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<ThreadsResultList<VideoComment>>(url, { params })
return this.authHttp.get<ThreadsResultList<VideoComment>>(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<VideoCommentThreadTree> {
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<VideoCommentThreadTreeServerModel>(url)
.get<VideoCommentThreadTreeServerModel>(url, { headers })
.pipe(
map(tree => this.extractVideoCommentTree(tree)),
catchError(err => this.restExtractor.handleError(err))

View file

@ -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) {

View file

@ -125,7 +125,7 @@
<my-peertube-checkbox
formControlName="allVideos"
inputName="allVideos"
i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
></my-peertube-checkbox>
</div>
</div>

View file

@ -5,6 +5,7 @@
>
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
</my-video-thumbnail>
<div class="video-bottom">

View file

@ -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 ''

View file

@ -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)

View file

@ -21,7 +21,8 @@
[attr.title]="playlistElement.video.name"
>{{ playlistElement.video.name }}</a>
<span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
<span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
<span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
</div>
<span class="video-miniature-created-at-views">

View file

@ -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
}

View file

@ -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),

View file

@ -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() }) ]
},

View file

@ -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<SegmentsJSON> {
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<SegmentsJSON>)

View file

@ -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() })
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
// ---------------------------------------------------------------------------

View file

@ -41,6 +41,23 @@
<div id="error-content"></div>
</div>
<div id="video-password-block">
<!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
<h1 id="video-password-title"></h1>
<div id="video-password-content"></div>
<form id="video-password-form">
<input type="password" id="video-password-input" name="video-password" required>
<button type="submit" id="video-password-submit"> </button>
</form>
<div id="video-password-error"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24">
<g fill="none" stroke="#c4c4c4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></g>
</svg>
</div>
<div id="video-wrapper"></div>
<div id="placeholder-preview"></div>

View file

@ -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;

View file

@ -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()

View file

@ -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 })
}

View file

@ -55,6 +55,58 @@ export class PlayerHTML {
this.wrapperElement.style.display = 'none'
}
async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> {
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()

View file

@ -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
},

View file

@ -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<LiveVideo>)
}
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<VideoToken>)
.then(token => token.files.token)
}
@ -51,12 +55,12 @@ export class VideoFetcher {
return this.getVideoUrl(videoUUID) + '/views'
}
private loadVideoInfo (videoId: string): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
}
private loadVideoCaptions (videoId: string): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
}
private getVideoUrl (id: string) {

View file

@ -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'

View file

@ -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
}
}

View file

@ -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)

View file

@ -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,

View file

@ -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' }),

View file

@ -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 }

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 })

View file

@ -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))

View file

@ -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
}

View file

@ -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 } = {

View file

@ -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

View file

@ -0,0 +1,31 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
{
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
}

View file

@ -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
}

View file

@ -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<VideoImportModel>
user: MUser
videoPasswords?: string[]
}): Promise<MVideoImportFormattable> {
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

View file

@ -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) {

View file

@ -12,26 +12,34 @@ class VideoTokensManager {
private static instance: VideoTokensManager
private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
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 }
}
}
// ---------------------------------------------------------------------------

View file

@ -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<void>(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
})
}

View file

@ -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'

View file

@ -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
}

View file

@ -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<MVideoWithRights> {
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)
}

View file

@ -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)

View file

@ -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<string, LRUValue>({
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
})
}

View file

@ -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'

View file

@ -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

View file

@ -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),

View file

@ -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)

View file

@ -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()

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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<Partial<AttributesOnly<VideoPasswordModel>>> {
@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<MVideoPassword> {
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<ResultList<MVideoPassword>> {
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<void> {
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
}
}
}

View file

@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
// Internal video?
if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
// Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
return VideoPlaylistElementType.PRIVATE
}
if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE

View file

@ -136,6 +136,7 @@ import { VideoFileModel } from './video-file'
import { VideoImportModel } from './video-import'
import { VideoJobInfoModel } from './video-job-info'
import { VideoLiveModel } from './video-live'
import { VideoPasswordModel } from './video-password'
import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share'
import { VideoSourceModel } from './video-source'
@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
})
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<Partial<AttributesOnly<VideoModel>>> {
// ---------------------------------------------------------------------------
requiresAuth (options: {
requiresUserAuth (options: {
urlParamId: string
checkBlacklist: boolean
}) {
@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
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 () {

View file

@ -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 })
})

View file

@ -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 ])
})
})

View file

@ -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 () {

View file

@ -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)

View file

@ -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 ])
})
})

View file

@ -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)

View file

@ -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 () {

View file

@ -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')
}
}
})

View file

@ -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 })

Some files were not shown because too many files have changed in this diff Show more