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) { if (filters.excludePublic) {
privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
filters.excludePublic = undefined filters.excludePublic = undefined
} }

View File

@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
async disableTwoFactor () { async disableTwoFactor () {
const message = $localize`Are you sure you want to disable two factor authentication of your account?` 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 if (confirmed === false) return
this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })

View File

@ -120,7 +120,12 @@
</div> </div>
</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> <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
<p-calendar <p-calendar
id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
@ -287,7 +292,7 @@
<div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
<label i18n for="replayPrivacy">Privacy of the new replay</label> <label i18n for="replayPrivacy">Privacy of the new replay</label>
<my-select-options <my-select-options
labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
></my-select-options> ></my-select-options>
</div> </div>

View File

@ -14,6 +14,7 @@ import {
VIDEO_LICENCE_VALIDATOR, VIDEO_LICENCE_VALIDATOR,
VIDEO_NAME_VALIDATOR, VIDEO_NAME_VALIDATOR,
VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
VIDEO_PASSWORD_VALIDATOR,
VIDEO_PRIVACY_VALIDATOR, VIDEO_PRIVACY_VALIDATOR,
VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
VIDEO_SUPPORT_VALIDATOR, VIDEO_SUPPORT_VALIDATOR,
@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
// So that it can be accessed in the template // So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 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>[] = [] videoCategories: VideoConstant<number>[] = []
videoLicences: VideoConstant<number>[] = [] videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoLanguages[] = [] videoLanguages: VideoLanguages[] = []
@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
pluginDataFormGroup: FormGroup pluginDataFormGroup: FormGroup
schedulePublicationEnabled = false schedulePublicationSelected = false
passwordProtectionSelected = false
calendarLocale: any = {} calendarLocale: any = {}
minScheduledDate = new Date() minScheduledDate = new Date()
@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
const obj: { [ id: string ]: BuildFormValidator } = { const obj: { [ id: string ]: BuildFormValidator } = {
name: VIDEO_NAME_VALIDATOR, name: VIDEO_NAME_VALIDATOR,
privacy: VIDEO_PRIVACY_VALIDATOR, privacy: VIDEO_PRIVACY_VALIDATOR,
videoPassword: VIDEO_PASSWORD_VALIDATOR,
channelId: VIDEO_CHANNEL_VALIDATOR, channelId: VIDEO_CHANNEL_VALIDATOR,
nsfw: null, nsfw: null,
commentsEnabled: null, commentsEnabled: null,
@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.serverService.getVideoPrivacies() this.serverService.getVideoPrivacies()
.subscribe(privacies => { .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) // 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) const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
.subscribe( .subscribe(
newPrivacyId => { newPrivacyId => {
this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
// Value changed // Value changed
const scheduleControl = this.form.get('schedulePublicationAt') const scheduleControl = this.form.get('schedulePublicationAt')
const waitTranscodingControl = this.form.get('waitTranscoding') const waitTranscodingControl = this.form.get('waitTranscoding')
if (this.schedulePublicationEnabled) { if (this.schedulePublicationSelected) {
scheduleControl.setValidators([ Validators.required ]) scheduleControl.setValidators([ Validators.required ])
waitTranscodingControl.disable() waitTranscodingControl.disable()
@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.firstPatchDone = true 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({}) this.buildForm({})
const { videoData } = this.route.snapshot.data 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.videoDetails = video
this.videoEdit = new VideoEdit(this.videoDetails) this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
this.userVideoChannels = videoChannels this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions

View File

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

View File

@ -1,7 +1,7 @@
<div class="video-actions-rates"> <div class="video-actions-rates">
<div class="video-actions justify-content-end"> <div class="video-actions justify-content-end">
<my-video-rate <my-video-rate
[video]="video" [isUserLoggedIn]="isUserLoggedIn" [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
(rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
></my-video-rate> ></my-video-rate>
@ -20,7 +20,7 @@
<div <div
class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
*ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)" *ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
[ngbTooltip]="tooltipSaveToPlaylist" [ngbTooltip]="tooltipSaveToPlaylist"
placement="bottom auto" placement="bottom auto"
> >
@ -43,7 +43,7 @@
<span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
</button> </button>
<my-video-download #videoDownloadModal></my-video-download> <my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
</ng-container> </ng-container>
<ng-container *ngIf="isUserLoggedIn"> <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 { SupportModalComponent } from '@app/shared/shared-support-modal'
import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
import { VideoPlaylist } from '@app/shared/shared-video-playlist' import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { UserVideoRateType, VideoCaption } from '@shared/models/videos' import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
@Component({ @Component({
selector: 'my-action-buttons', selector: 'my-action-buttons',
@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
@Input() video: VideoDetails @Input() video: VideoDetails
@Input() videoPassword: string
@Input() videoCaptions: VideoCaption[] @Input() videoCaptions: VideoCaption[]
@Input() playlist: VideoPlaylist @Input() playlist: VideoPlaylist
@Input() isUserLoggedIn: boolean @Input() isUserLoggedIn: boolean
@Input() isUserOwner: boolean
@Input() currentTime: number @Input() currentTime: number
@Input() currentPlaylistPosition: number @Input() currentPlaylistPosition: number
@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
private setVideoLikesBarTooltipText () { private setVideoLikesBarTooltipText () {
this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` 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 { export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
@Input() video: VideoDetails @Input() video: VideoDetails
@Input() videoPassword: string
@Input() isUserLoggedIn: boolean @Input() isUserLoggedIn: boolean
@Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
} }
private setRating (nextRating: UserVideoRateType) { 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, like: this.videoService.setVideoLike,
dislike: this.videoService.setVideoDislike, dislike: this.videoService.setVideoDislike,
none: this.videoService.unsetVideoLike none: this.videoService.unsetVideoLike
} }
ratingMethods[nextRating].call(this.videoService, this.video.uuid) ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
.subscribe({ .subscribe({
next: () => { next: () => {
// Update the video like attribute // Update the video like attribute

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,3 +42,7 @@
<div class="blocked-label" i18n>This video is blocked.</div> <div class="blocked-label" i18n>This video is blocked.</div>
{{ video.blacklistedReason }} {{ video.blacklistedReason }}
</div> </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 { Component, Input } from '@angular/core'
import { AuthUser } from '@app/core'
import { VideoDetails } from '@app/shared/shared-main' import { VideoDetails } from '@app/shared/shared-main'
import { VideoState } from '@shared/models' import { VideoPrivacy, VideoState } from '@shared/models'
@Component({ @Component({
selector: 'my-video-alert', selector: 'my-video-alert',
@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
styleUrls: [ './video-alert.component.scss' ] styleUrls: [ './video-alert.component.scss' ]
}) })
export class VideoAlertComponent { export class VideoAlertComponent {
@Input() user: AuthUser
@Input() video: VideoDetails @Input() video: VideoDetails
@Input() noPlaylistVideoFound: boolean @Input() noPlaylistVideoFound: boolean
@ -46,4 +48,8 @@ export class VideoAlertComponent {
isLiveEnded () { isLiveEnded () {
return this.video?.state.id === VideoState.LIVE_ENDED 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> <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
</div> </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 --> <!-- Video information -->
<div *ngIf="video" class="margin-content video-bottom"> <div *ngIf="video" class="margin-content video-bottom">
@ -51,8 +51,8 @@
</div> </div>
<my-action-buttons <my-action-buttons
[video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist" [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
[currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
></my-action-buttons> ></my-action-buttons>
</div> </div>
</div> </div>
@ -92,6 +92,7 @@
<my-video-comments <my-video-comments
class="border-top" class="border-top"
[video]="video" [video]="video"
[videoPassword]="videoPassword"
[user]="user" [user]="user"
(timestampClicked)="handleTimestampClicked($event)" (timestampClicked)="handleTimestampClicked($event)"
></my-video-comments> ></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 { LiveVideoService } from '@app/shared/shared-video-live'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { logger } from '@root-helpers/logger' 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 { timeToInt } from '@shared/core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null video: VideoDetails = null
videoCaptions: VideoCaption[] = [] videoCaptions: VideoCaption[] = []
liveVideo: LiveVideo liveVideo: LiveVideo
videoPassword: string
playlistPosition: number playlistPosition: number
playlist: VideoPlaylist = null playlist: VideoPlaylist = null
@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.authService.isLoggedIn() return this.authService.isLoggedIn()
} }
isUserOwner () {
return this.video.isLocal === true && this.video.account.name === this.user?.username
}
isVideoBlur (video: Video) { isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.user, this.serverConfig) return video.isVideoNSFWForUser(this.user, this.serverConfig)
} }
@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private loadVideo (options: { private loadVideo (options: {
videoId: string videoId: string
forceAutoplay: boolean forceAutoplay: boolean
videoPassword?: string
}) { }) {
const { videoId, forceAutoplay } = options const { videoId, forceAutoplay, videoPassword } = options
if (this.isSameElement(this.video, videoId)) return if (this.isSameElement(this.video, videoId)) return
@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const videoObs = this.hooks.wrapObsFun( const videoObs = this.hooks.wrapObsFun(
this.videoService.getVideo.bind(this.videoService), this.videoService.getVideo.bind(this.videoService),
{ videoId }, { videoId, videoPassword },
'video-watch', 'video-watch',
'filter:api.video-watch.video.get.params', 'filter:api.video-watch.video.get.params',
'filter:api.video-watch.video.get.result' 'filter:api.video-watch.video.get.result'
@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}), }),
switchMap(({ video, live }) => { 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 }))) .pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
}) })
) )
forkJoin([ forkJoin([
videoAndLiveObs, videoAndLiveObs,
this.videoCaptionService.listCaptions(videoId), this.videoCaptionService.listCaptions(videoId, videoPassword),
this.userService.getAnonymousOrLoggedUser() this.userService.getAnonymousOrLoggedUser()
]).subscribe({ ]).subscribe({
next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
live, live,
videoCaptions: captionsResult.data, videoCaptions: captionsResult.data,
videoFileToken, videoFileToken,
videoPassword,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
urlOptions, urlOptions,
forceAutoplay 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) 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: { private async onVideoFetched (options: {
video: VideoDetails video: VideoDetails
live: LiveVideo live: LiveVideo
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
videoFileToken: string videoFileToken: string
videoPassword: string
urlOptions: URLOptions urlOptions: URLOptions
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
forceAutoplay: boolean 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) this.subscribeToLiveEventsIfNeeded(this.video, video)
@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
this.liveVideo = live this.liveVideo = live
this.videoFileToken = videoFileToken this.videoFileToken = videoFileToken
this.videoPassword = videoPassword
// Re init attributes // Re init attributes
this.playerPlaceholderImgSrc = undefined this.playerPlaceholderImgSrc = undefined
@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: this.videoCaptions, videoCaptions: this.videoCaptions,
liveVideo: this.liveVideo, liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken, videoFileToken: this.videoFileToken,
videoPassword: this.videoPassword,
urlOptions, urlOptions,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
forceAutoplay, forceAutoplay,
@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
videoFileToken: string videoFileToken: string
videoPassword: string
urlOptions: CustomizationOptions & { playerMode: PlayerMode } urlOptions: CustomizationOptions & { playerMode: PlayerMode }
@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
forceAutoplay: boolean forceAutoplay: boolean
user?: AuthUser // Keep for plugins 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 getStartTime = () => {
const byUrl = urlOptions.startTime !== undefined const byUrl = urlOptions.startTime !== undefined
@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
serverUrl: environment.originServerUrl || window.location.origin, serverUrl: environment.originServerUrl || window.location.origin,
videoFileToken: () => videoFileToken, videoFileToken: () => videoFileToken,
requiresAuth: videoRequiresAuth(video), requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
!video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
videoPassword: () => videoPassword,
videoCaptions: playerCaptions, videoCaptions: playerCaptions,

View File

@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
type ConfirmOptions = { type ConfirmOptions = {
title: string title: string
message: string message: string
errorMessage?: string
} & ( } & (
{ {
type: 'confirm' type: 'confirm'
@ -12,6 +13,7 @@ type ConfirmOptions = {
{ {
type: 'confirm-password' type: 'confirm-password'
confirmButtonText?: string confirmButtonText?: string
isIncorrectPassword?: boolean
} | } |
{ {
type: 'confirm-expected-input' type: 'confirm-expected-input'
@ -32,8 +34,14 @@ export class ConfirmService {
return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
} }
confirmWithPassword (message: string, title = '', confirmButtonText?: string) { confirmWithPassword (options: {
this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) 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() const obs = this.confirmResponse.asObservable()
.pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))

View File

@ -12,10 +12,12 @@
<div *ngIf="inputLabel" class="form-group mt-3"> <div *ngIf="inputLabel" class="form-group mt-3">
<label for="confirmInput">{{ inputLabel }}</label> <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>
<div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
</div> </div>
<div class="modal-footer inputs"> <div class="modal-footer inputs">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core' import { RestExtractor } from '@app/core'
import { VideoToken } from '@shared/models' import { VideoToken } from '@shared/models'
import { VideoService } from './video.service' import { VideoService } from './video.service'
import { VideoPasswordService } from './video-password.service'
@Injectable() @Injectable()
export class VideoFileTokenService { export class VideoFileTokenService {
@ -15,16 +16,18 @@ export class VideoFileTokenService {
private restExtractor: RestExtractor private restExtractor: RestExtractor
) {} ) {}
getVideoFileToken (videoUUID: string) { getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
const existing = this.store.get(videoUUID) const existing = this.store.get(videoUUID)
if (existing) return of(existing) 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) }))) .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
} }
private createVideoFileToken (videoUUID: string) { private createVideoFileToken (videoUUID: string, videoPassword?: string) {
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
.pipe( .pipe(
map(({ files }) => files), map(({ files }) => files),
catchError(err => this.restExtractor.handleError(err)) 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) 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 () { getExactNumberOfViews () {
if (this.isLive) { if (this.isLive) {
return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) 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 { VideoDetails } from './video-details.model'
import { VideoEdit } from './video-edit.model' import { VideoEdit } from './video-edit.model'
import { Video } from './video.model' import { Video } from './video.model'
import { VideoPasswordService } from './video-password.service'
export type CommonVideoParams = { export type CommonVideoParams = {
videoPagination?: ComponentPaginationLight videoPagination?: ComponentPaginationLight
@ -69,16 +70,17 @@ export class VideoService {
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
} }
getVideo (options: { videoId: string }): Observable<VideoDetails> { getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
return this.serverService.getServerLocale() const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
.pipe(
switchMap(translations => { return this.serverService.getServerLocale().pipe(
return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`) switchMap(translations => {
.pipe(map(videoHash => ({ videoHash, 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)) map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
) catchError(err => this.restExtractor.handleError(err))
)
} }
updateVideo (video: VideoEdit) { updateVideo (video: VideoEdit) {
@ -99,6 +101,9 @@ export class VideoService {
description, description,
channelId: video.channelId, channelId: video.channelId,
privacy: video.privacy, privacy: video.privacy,
videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
? [ video.videoPassword ]
: undefined,
tags: video.tags, tags: video.tags,
nsfw: video.nsfw, nsfw: video.nsfw,
waitTranscoding: video.waitTranscoding, waitTranscoding: video.waitTranscoding,
@ -353,16 +358,16 @@ export class VideoService {
) )
} }
setVideoLike (id: string) { setVideoLike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'like') return this.setVideoRate(id, 'like', videoPassword)
} }
setVideoDislike (id: string) { setVideoDislike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'dislike') return this.setVideoRate(id, 'dislike', videoPassword)
} }
unsetVideoLike (id: string) { unsetVideoLike (id: string, videoPassword: string) {
return this.setVideoRate(id, 'none') return this.setVideoRate(id, 'none', videoPassword)
} }
getUserVideoRating (id: string) { getUserVideoRating (id: string) {
@ -394,7 +399,8 @@ export class VideoService {
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
[VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
[VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, [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 => { const videoPrivacies = serverPrivacies.map(p => {
@ -412,7 +418,13 @@ export class VideoService {
} }
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { 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) { for (const privacy of order) {
if (serverPrivacies.find(p => p.id === privacy)) { 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 url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
const body: UserVideoRateUpdate = { const body: UserVideoRateUpdate = {
rating: rateType rating: rateType
} }
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp return this.authHttp
.put(url, body) .put(url, body, { headers })
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
} }

View File

@ -107,6 +107,10 @@
</a> </a>
</div> </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"> <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
<ng-container ngbNavItem="url"> <ng-container ngbNavItem="url">

View File

@ -243,6 +243,10 @@ export class VideoShareComponent {
return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
} }
isPasswordProtectedVideo () {
return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
private getPlaylistOptions (baseUrl?: string) { private getPlaylistOptions (baseUrl?: string) {
return { return {
baseUrl, baseUrl,

View File

@ -18,6 +18,7 @@ import {
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { VideoCommentThreadTree } from './video-comment-thread-tree.model' import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model' import { VideoComment } from './video-comment.model'
import { VideoPasswordService } from '../shared-main'
@Injectable() @Injectable()
export class VideoCommentService { export class VideoCommentService {
@ -31,22 +32,25 @@ export class VideoCommentService {
private restService: RestService 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 url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
const normalizedComment = objectLineFeedToHtml(comment, 'text') const normalizedComment = objectLineFeedToHtml(comment, 'text')
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
.pipe( .pipe(
map(data => this.extractVideoComment(data.comment)), map(data => this.extractVideoComment(data.comment)),
catchError(err => this.restExtractor.handleError(err)) 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 url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
const normalizedComment = objectLineFeedToHtml(comment, 'text') const normalizedComment = objectLineFeedToHtml(comment, 'text')
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
.pipe( .pipe(
map(data => this.extractVideoComment(data.comment)), map(data => this.extractVideoComment(data.comment)),
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
@ -76,10 +80,13 @@ export class VideoCommentService {
getVideoCommentThreads (parameters: { getVideoCommentThreads (parameters: {
videoId: string videoId: string
videoPassword: string
componentPagination: ComponentPaginationLight componentPagination: ComponentPaginationLight
sort: string sort: string
}): Observable<ThreadsResultList<VideoComment>> { }): 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) const pagination = this.restService.componentToRestPagination(componentPagination)
@ -87,7 +94,7 @@ export class VideoCommentService {
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 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( .pipe(
map(result => this.extractVideoComments(result)), map(result => this.extractVideoComments(result)),
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
@ -97,12 +104,14 @@ export class VideoCommentService {
getVideoThreadComments (parameters: { getVideoThreadComments (parameters: {
videoId: string videoId: string
threadId: number threadId: number
videoPassword?: string
}): Observable<VideoCommentThreadTree> { }): Observable<VideoCommentThreadTree> {
const { videoId, threadId } = parameters const { videoId, threadId, videoPassword } = parameters
const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
return this.authHttp return this.authHttp
.get<VideoCommentThreadTreeServerModel>(url) .get<VideoCommentThreadTreeServerModel>(url, { headers })
.pipe( .pipe(
map(tree => this.extractVideoCommentTree(tree)), map(tree => this.extractVideoCommentTree(tree)),
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))

View File

@ -1,13 +1,13 @@
import { mapValues } from 'lodash-es' import { mapValues } from 'lodash-es'
import { firstValueFrom } from 'rxjs' import { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators' 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 { HooksService } from '@app/core'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { logger } from '@root-helpers/logger' 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 { 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' import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
type DownloadType = 'video' | 'subtitles' type DownloadType = 'video' | 'subtitles'
@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
export class VideoDownloadComponent { export class VideoDownloadComponent {
@ViewChild('modal', { static: true }) modal: ElementRef @ViewChild('modal', { static: true }) modal: ElementRef
@Input() videoPassword: string
downloadType: 'direct' | 'torrent' = 'direct' downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | string = -1 resolutionId: number | string = -1
@ -89,8 +91,8 @@ export class VideoDownloadComponent {
this.subtitleLanguageId = this.videoCaptions[0].language.id this.subtitleLanguageId = this.videoCaptions[0].language.id
} }
if (videoRequiresAuth(this.video)) { if (this.isConfidentialVideo()) {
this.videoFileTokenService.getVideoFileToken(this.video.uuid) this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => this.videoFileToken = token) .subscribe(({ token }) => this.videoFileToken = token)
} }
@ -201,7 +203,8 @@ export class VideoDownloadComponent {
} }
isConfidentialVideo () { isConfidentialVideo () {
return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL return videoRequiresFileToken(this.video)
} }
switchToType (type: DownloadType) { switchToType (type: DownloadType) {

View File

@ -125,7 +125,7 @@
<my-peertube-checkbox <my-peertube-checkbox
formControlName="allVideos" formControlName="allVideos"
inputName="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> ></my-peertube-checkbox>
</div> </div>
</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-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 && isPrivateVideo()" i18n>Private</ng-container>
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
</my-video-thumbnail> </my-video-thumbnail>
<div class="video-bottom"> <div class="video-bottom">

View File

@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
return this.video.privacy.id === VideoPrivacy.PRIVATE return this.video.privacy.id === VideoPrivacy.PRIVATE
} }
isPasswordProtectedVideo () {
return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
getStateLabel (video: Video) { getStateLabel (video: Video) {
if (!video.state) return '' if (!video.state) return ''

View File

@ -241,6 +241,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
} }
reloadVideos () { reloadVideos () {
console.log('reload')
this.pagination.currentPage = 1 this.pagination.currentPage = 1
this.loadMoreVideos(true) this.loadMoreVideos(true)
} }
@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
if (reset) this.videos = [] if (reset) this.videos = []
this.videos = this.videos.concat(data) this.videos = this.videos.concat(data)
console.log('subscribe')
if (this.groupByDate) this.buildGroupedDateLabels() if (this.groupByDate) this.buildGroupedDateLabels()
this.onDataSubject.next(data) this.onDataSubject.next(data)

View File

@ -21,7 +21,8 @@
[attr.title]="playlistElement.video.name" [attr.title]="playlistElement.video.name"
>{{ playlistElement.video.name }}</a> >{{ 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> </div>
<span class="video-miniature-created-at-views"> <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 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
} }
isVideoPasswordProtected () {
return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
}
isUnavailable (e: VideoPlaylistElement) { isUnavailable (e: VideoPlaylistElement) {
return e.type === VideoPlaylistElementType.UNAVAILABLE 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 loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
const p2pMediaLoader: P2PMediaLoaderPluginOptions = { const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
requiresAuth: commonOptions.requiresAuth, requiresUserAuth: commonOptions.requiresUserAuth,
videoFileToken: commonOptions.videoFileToken, videoFileToken: commonOptions.videoFileToken,
redundancyUrlManager, redundancyUrlManager,
@ -88,17 +88,24 @@ export class HLSOptionsBuilder {
httpFailedSegmentTimeout: 1000, httpFailedSegmentTimeout: 1000,
xhrSetup: (xhr, url) => { 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 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({ segmentValidator: segmentValidatorFactory({
segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
authorizationHeader: this.options.common.authorizationHeader, authorizationHeader: this.options.common.authorizationHeader,
requiresAuth: this.options.common.requiresAuth, requiresUserAuth: this.options.common.requiresUserAuth,
serverUrl: this.options.common.serverUrl serverUrl: this.options.common.serverUrl,
requiresPassword: this.options.common.requiresPassword,
videoPassword: this.options.common.videoPassword
}), }),
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),

View File

@ -26,10 +26,10 @@ export class WebTorrentOptionsBuilder {
videoFileToken: commonOptions.videoFileToken, videoFileToken: commonOptions.videoFileToken,
requiresAuth: commonOptions.requiresAuth, requiresUserAuth: commonOptions.requiresUserAuth,
buildWebSeedUrls: file => { buildWebSeedUrls: file => {
if (!commonOptions.requiresAuth) return [] if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return []
return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
}, },

View File

@ -13,11 +13,20 @@ function segmentValidatorFactory (options: {
serverUrl: string serverUrl: string
segmentsSha256Url: string segmentsSha256Url: string
authorizationHeader: () => 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+)/ const regex = /bytes=(\d+)-(\d+)/
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
@ -34,7 +43,14 @@ function segmentValidatorFactory (options: {
await wait(500) await wait(500)
segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) segmentsJSON = fetchSha256Segments({
serverUrl,
segmentsSha256Url,
authorizationHeader,
requiresUserAuth,
requiresPassword,
videoPassword
})
await segmentValidator(segment, _method, _peerId, retry + 1) await segmentValidator(segment, _method, _peerId, retry + 1)
return return
@ -78,13 +94,17 @@ function fetchSha256Segments (options: {
serverUrl: string serverUrl: string
segmentsSha256Url: string segmentsSha256Url: string
authorizationHeader: () => string authorizationHeader: () => string
requiresAuth: boolean requiresUserAuth: boolean
requiresPassword: boolean
videoPassword: () => string
}): Promise<SegmentsJSON> { }): Promise<SegmentsJSON> {
const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options
const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url) let headers: { [ id: string ]: string } = {}
? { Authorization: authorizationHeader() } if (isSameOrigin(serverUrl, segmentsSha256Url)) {
: {} if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() }
else if (requiresUserAuth) headers = { Authorization: authorizationHeader() }
}
return fetch(segmentsSha256Url, { headers }) return fetch(segmentsSha256Url, { headers })
.then(res => res.json() as Promise<SegmentsJSON>) .then(res => res.json() as Promise<SegmentsJSON>)

View File

@ -59,7 +59,7 @@ class WebTorrentPlugin extends Plugin {
private isAutoResolutionObservation = false private isAutoResolutionObservation = false
private playerRefusedP2P = false private playerRefusedP2P = false
private requiresAuth: boolean private requiresUserAuth: boolean
private videoFileToken: () => string private videoFileToken: () => string
private torrentInfoInterval: any private torrentInfoInterval: any
@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin {
this.savePlayerSrcFunction = this.player.src this.savePlayerSrcFunction = this.player.src
this.playerElement = options.playerElement this.playerElement = options.playerElement
this.requiresAuth = options.requiresAuth this.requiresUserAuth = options.requiresUserAuth
this.videoFileToken = options.videoFileToken this.videoFileToken = options.videoFileToken
this.buildWebSeedUrls = options.buildWebSeedUrls this.buildWebSeedUrls = options.buildWebSeedUrls
@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin {
let httpUrl = this.currentVideoFile.fileUrl let httpUrl = this.currentVideoFile.fileUrl
if (this.requiresAuth && this.videoFileToken) { if (this.videoFileToken) {
httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
} }

View File

@ -83,8 +83,10 @@ export interface CommonOptions extends CustomizationOptions {
videoShortUUID: string videoShortUUID: string
serverUrl: string serverUrl: string
requiresAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
requiresPassword: boolean
videoPassword: () => string
errorNotifier: (message: string) => void errorNotifier: (message: string) => void
} }

View File

@ -155,7 +155,7 @@ type WebtorrentPluginOptions = {
playerRefusedP2P: boolean playerRefusedP2P: boolean
requiresAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
buildWebSeedUrls: (file: VideoFile) => string[] buildWebSeedUrls: (file: VideoFile) => string[]
@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = {
loader: P2PMediaLoader loader: P2PMediaLoader
requiresAuth: boolean requiresUserAuth: boolean
videoFileToken: () => string videoFileToken: () => string
} }

View File

@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b
return userP2PEnabled return userP2PEnabled
} }
function videoRequiresAuth (video: Video) { function videoRequiresUserAuth (video: Video, videoPassword?: string) {
return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) 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 { export {
buildVideoOrPlaylistEmbed, buildVideoOrPlaylistEmbed,
isP2PEnabled, isP2PEnabled,
videoRequiresAuth videoRequiresUserAuth,
videoRequiresFileToken
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -41,6 +41,23 @@
<div id="error-content"></div> <div id="error-content"></div>
</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="video-wrapper"></div>
<div id="placeholder-preview"></div> <div id="placeholder-preview"></div>

View File

@ -24,7 +24,7 @@ html,
body { body {
height: 100%; height: 100%;
margin: 0; margin: 0;
background-color: #000; background-color: #0f0f10;
} }
#video-wrapper { #video-wrapper {
@ -42,8 +42,10 @@ body {
} }
} }
#error-block { #error-block,
#video-password-block {
display: none; display: none;
user-select: none;
flex-direction: column; flex-direction: column;
align-content: center; align-content: center;
@ -86,6 +88,43 @@ body {
text-align: center; 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) { @media screen and (max-width: 300px) {
#error-block { #error-block {
font-size: 36px; 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 '../../assets/player/shared/dock/peertube-dock-plugin'
import videojs from 'video.js' import videojs from 'video.js'
import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 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 { PeertubePlayerManager } from '../../assets/player'
import { TranslationsManager } from '../../assets/player/translations-manager' 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 { PeerTubeEmbedApi } from './embed-api'
import { import {
AuthHTTP, AuthHTTP,
@ -19,6 +27,7 @@ import {
VideoFetcher VideoFetcher
} from './shared' } from './shared'
import { PlayerHTML } from './shared/player-html' import { PlayerHTML } from './shared/player-html'
import { PeerTubeServerError } from 'src/types'
export class PeerTubeEmbed { export class PeerTubeEmbed {
player: videojs.Player player: videojs.Player
@ -38,6 +47,8 @@ export class PeerTubeEmbed {
private readonly liveManager: LiveManager private readonly liveManager: LiveManager
private playlistTracker: PlaylistTracker private playlistTracker: PlaylistTracker
private videoPassword: string
private requiresPassword: boolean
constructor (videoWrapperId: string) { constructor (videoWrapperId: string) {
logger.registerServerSending(window.location.origin) logger.registerServerSending(window.location.origin)
@ -50,6 +61,7 @@ export class PeerTubeEmbed {
this.playerHTML = new PlayerHTML(videoWrapperId) this.playerHTML = new PlayerHTML(videoWrapperId)
this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
this.liveManager = new LiveManager(this.playerHTML) this.liveManager = new LiveManager(this.playerHTML)
this.requiresPassword = false
try { try {
this.config = JSON.parse((window as any)['PeerTubeServerConfig']) this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
@ -176,11 +188,13 @@ export class PeerTubeEmbed {
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
try { 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 }) return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay })
} catch (err) { } 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) ? await this.videoFetcher.loadLive(videoInfo)
: undefined : undefined
const videoFileToken = videoRequiresAuth(videoInfo) const videoFileToken = videoRequiresFileToken(videoInfo)
? await this.videoFetcher.loadVideoToken(videoInfo) ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
: undefined : undefined
return { live, video: videoInfo, videoFileToken } return { live, video: videoInfo, videoFileToken }
@ -232,6 +246,8 @@ export class PeerTubeEmbed {
authorizationHeader: () => this.http.getHeaderTokenValue(), authorizationHeader: () => this.http.getHeaderTokenValue(),
videoFileToken: () => videoFileToken, videoFileToken: () => videoFileToken,
videoPassword: () => this.videoPassword,
requiresPassword: this.requiresPassword,
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
@ -263,6 +279,7 @@ export class PeerTubeEmbed {
this.initializeApi() this.initializeApi()
this.playerHTML.removePlaceholder() this.playerHTML.removePlaceholder()
if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
if (this.isPlaylistEmbed()) { if (this.isPlaylistEmbed()) {
await this.buildPlayerPlaylistUpnext() await this.buildPlayerPlaylistUpnext()
@ -401,6 +418,21 @@ export class PeerTubeEmbed {
(this.player.el() as HTMLElement).style.pointerEvents = 'none' (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() PeerTubeEmbed.main()

View File

@ -18,10 +18,12 @@ export class AuthHTTP {
if (this.userOAuthTokens) this.setHeadersFromTokens() if (this.userOAuthTokens) this.setHeadersFromTokens()
} }
fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
const refreshFetchOptions = optionalAuth let refreshFetchOptions: { headers?: Headers } = {}
? { headers: this.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 }) return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
} }

View File

@ -55,6 +55,58 @@ export class PlayerHTML {
this.wrapperElement.style.display = 'none' 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) { buildPlaceholder (video: VideoDetails) {
const placeholder = this.getPlaceholderElement() const placeholder = this.getPlaceholderElement()

View File

@ -18,7 +18,7 @@ import {
logger, logger,
peertubeLocalStorage, peertubeLocalStorage,
UserLocalStorageKeys, UserLocalStorageKeys,
videoRequiresAuth videoRequiresUserAuth
} from '../../../root-helpers' } from '../../../root-helpers'
import { PeerTubePlugin } from './peertube-plugin' import { PeerTubePlugin } from './peertube-plugin'
import { PlayerHTML } from './player-html' import { PlayerHTML } from './player-html'
@ -162,6 +162,9 @@ export class PlayerManagerOptions {
authorizationHeader: () => string authorizationHeader: () => string
videoFileToken: () => string videoFileToken: () => string
videoPassword: () => string
requiresPassword: boolean
serverConfig: HTMLServerConfig serverConfig: HTMLServerConfig
autoplayFromPreviousVideo: boolean autoplayFromPreviousVideo: boolean
@ -178,6 +181,8 @@ export class PlayerManagerOptions {
captionsResponse, captionsResponse,
autoplayFromPreviousVideo, autoplayFromPreviousVideo,
videoFileToken, videoFileToken,
videoPassword,
requiresPassword,
translations, translations,
forceAutoplay, forceAutoplay,
playlistTracker, playlistTracker,
@ -242,10 +247,13 @@ export class PlayerManagerOptions {
embedUrl: window.location.origin + video.embedPath, embedUrl: window.location.origin + video.embedPath,
embedTitle: video.name, embedTitle: video.name,
requiresAuth: videoRequiresAuth(video), requiresUserAuth: videoRequiresUserAuth(video),
authorizationHeader, authorizationHeader,
videoFileToken, videoFileToken,
requiresPassword,
videoPassword,
errorNotifier: () => { errorNotifier: () => {
// Empty, we don't have a notifier in the embed // 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 { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
import { logger } from '../../../root-helpers' import { logger } from '../../../root-helpers'
import { AuthHTTP } from './auth-http' import { AuthHTTP } from './auth-http'
@ -8,8 +9,8 @@ export class VideoFetcher {
} }
async loadVideo (videoId: string) { async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
const videoPromise = this.loadVideoInfo(videoId) const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
let videoResponse: Response let videoResponse: Response
let isResponseOk: boolean let isResponseOk: boolean
@ -27,11 +28,14 @@ export class VideoFetcher {
if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
throw new Error('This video does not exist.') 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.') 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 } return { captionsPromise, videoResponse }
} }
@ -41,8 +45,8 @@ export class VideoFetcher {
.then(res => res.json() as Promise<LiveVideo>) .then(res => res.json() as Promise<LiveVideo>)
} }
loadVideoToken (video: VideoDetails) { loadVideoToken (video: VideoDetails, videoPassword?: string) {
return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
.then(res => res.json() as Promise<VideoToken>) .then(res => res.json() as Promise<VideoToken>)
.then(token => token.files.token) .then(token => token.files.token)
} }
@ -51,12 +55,12 @@ export class VideoFetcher {
return this.getVideoUrl(videoUUID) + '/views' return this.getVideoUrl(videoUUID) + '/views'
} }
private loadVideoInfo (videoId: string): Promise<Response> { private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
} }
private loadVideoCaptions (videoId: string): Promise<Response> { private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
} }
private getVideoUrl (id: string) { private getVideoUrl (id: string) {

View File

@ -1,4 +1,5 @@
export * from './client-script.model' export * from './client-script.model'
export * from './server-error.model'
export * from './job-state-client.type' export * from './job-state-client.type'
export * from './job-type-client.type' export * from './job-type-client.type'
export * from './link.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', '{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
'Previous video': 'Previous video', 'Previous video': 'Previous video',
'Video page (new window)': 'Video page (new window)', '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) Object.assign(playerKeys, videojs)

View File

@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
videoChannel: res.locals.videoChannel, videoChannel: res.locals.videoChannel,
tags: body.tags || undefined, tags: body.tags || undefined,
user, user,
videoPasswords: body.videoPasswords,
videoImportAttributes: { videoImportAttributes: {
magnetUri, magnetUri,
torrentName, torrentName,

View File

@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding'
import { updateRouter } from './update' import { updateRouter } from './update'
import { uploadRouter } from './upload' import { uploadRouter } from './upload'
import { viewRouter } from './view' import { viewRouter } from './view'
import { videoPasswordRouter } from './passwords'
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router() const videosRouter = express.Router()
@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter)
videosRouter.use('/', filesRouter) videosRouter.use('/', filesRouter)
videosRouter.use('/', transcodingRouter) videosRouter.use('/', transcodingRouter)
videosRouter.use('/', tokenRouter) videosRouter.use('/', tokenRouter)
videosRouter.use('/', videoPasswordRouter)
videosRouter.get('/categories', videosRouter.get('/categories',
openapiOperationDoc({ operationId: 'getCategories' }), 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 { VideoLiveSessionModel } from '@server/models/video/video-live-session'
import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
import { buildUUID, uuidToShort } from '@shared/extra-utils' 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 { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
import { VideoPasswordModel } from '@server/models/video/video-password'
const liveRouter = express.Router() const liveRouter = express.Router()
@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
await federateVideoIfNeeded(videoCreated, true, t) 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) logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
return { videoCreated } 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 express from 'express'
import { VideoTokensManager } from '@server/lib/video-tokens-manager' import { VideoTokensManager } from '@server/lib/video-tokens-manager'
import { VideoToken } from '@shared/models' import { VideoPrivacy, VideoToken } from '@shared/models'
import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
const tokenRouter = express.Router() const tokenRouter = express.Router()
tokenRouter.post('/:id/token', tokenRouter.post('/:id/token',
authenticate, optionalAuthenticate,
asyncMiddleware(videosCustomGetValidator('only-video')), asyncMiddleware(videosCustomGetValidator('only-video')),
videoFileTokenValidator,
generateToken generateToken
) )
@ -22,12 +23,11 @@ export {
function generateToken (req: express.Request, res: express.Response) { function generateToken (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo 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({ return res.json({
files: { files
token,
expires
}
} as VideoToken) } as VideoToken)
} }

View File

@ -2,13 +2,12 @@ import express from 'express'
import { Transaction } from 'sequelize/types' import { Transaction } from 'sequelize/types'
import { changeVideoChannelShare } from '@server/lib/activitypub/share' import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { setVideoPrivacy } from '@server/lib/video-privacy' import { setVideoPrivacy } from '@server/lib/video-privacy'
import { openapiOperationDoc } from '@server/middlewares/doc' import { openapiOperationDoc } from '@server/middlewares/doc'
import { FilteredModelAttributes } from '@server/types' import { FilteredModelAttributes } from '@server/types'
import { MVideoFullLight } from '@server/types/models' import { MVideoFullLight } from '@server/types/models'
import { forceNumber } from '@shared/core-utils' 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 { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils' import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { createReqFiles } from '../../../helpers/express-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 { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video' 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 lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
const newPrivacy = forceNumber(videoInfoToUpdate.privacy) const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
setVideoPrivacy(videoInstance, newPrivacy) 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 // Unfederate the video if the new privacy is not compatible with federation
if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
await VideoModel.sendDelete(videoInstance, { transaction }) 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 { VideoSourceModel } from '@server/models/video/video-source'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
import { uuidToShort } from '@shared/extra-utils' 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 { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { createReqFiles } from '../../../helpers/express-utils' import { createReqFiles } from '../../../helpers/express-utils'
import { logger, loggerTagsFactory } from '../../../helpers/logger' import { logger, loggerTagsFactory } from '../../../helpers/logger'
@ -33,6 +33,7 @@ import {
} from '../../../middlewares' } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoPasswordModel } from '@server/models/video/video-password'
const lTags = loggerTagsFactory('api', 'video') const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
@ -195,6 +196,10 @@ async function addVideo (options: {
transaction: t transaction: t
}) })
if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
}
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) 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)) 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 { decode as magnetUriDecode } from 'magnet-uri'
import validator from 'validator' import validator from 'validator'
import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
import { import {
CONSTRAINTS_FIELDS, CONSTRAINTS_FIELDS,
MIMETYPES, MIMETYPES,
@ -13,6 +13,7 @@ import {
VIDEO_STATES VIDEO_STATES
} from '../../initializers/constants' } from '../../initializers/constants'
import { exists, isArray, isDateValid, isFileValid } from './misc' import { exists, isArray, isDateValid, isFileValid } from './misc'
import { getVideoWithAttributes } from '@server/helpers/video'
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
return VIDEO_PRIVACIES[value] !== undefined return VIDEO_PRIVACIES[value] !== undefined
} }
function isVideoReplayPrivacyValid (value: number) {
return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
}
function isScheduleVideoUpdatePrivacyValid (value: number) { function isScheduleVideoUpdatePrivacyValid (value: number) {
return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
} }
@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
return parsed && isVideoFileInfoHashValid(parsed.infoHash) 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 { export {
@ -164,9 +212,12 @@ export {
isVideoDurationValid, isVideoDurationValid,
isVideoTagValid, isVideoTagValid,
isVideoPrivacyValid, isVideoPrivacyValid,
isVideoReplayPrivacyValid,
isVideoFileResolutionValid, isVideoFileResolutionValid,
isVideoFileSizeValid, isVideoFileSizeValid,
isVideoImageValid, isVideoImageValid,
isVideoSupportValid, 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_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
VIDEO_COMMENTS: [ 'createdAt' ], VIDEO_COMMENTS: [ 'createdAt' ],
VIDEO_PASSWORDS: [ 'createdAt' ],
VIDEO_RATES: [ 'createdAt' ], VIDEO_RATES: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = {
REASON: { min: 1, max: 5000 }, // Length REASON: { min: 1, max: 5000 }, // Length
ERROR_MESSAGE: { min: 1, max: 5000 }, // Length ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
PROGRESS: { min: 0, max: 100 } // Value 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.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.PRIVATE]: 'Private',
[VideoPrivacy.INTERNAL]: 'Internal' [VideoPrivacy.INTERNAL]: 'Internal',
[VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
} }
const VIDEO_STATES: { [ id in VideoState ]: string } = { 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 { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/view/video-view' import { VideoViewModel } from '../models/view/video-view'
import { CONFIG } from './config' import { CONFIG } from './config'
import { VideoPasswordModel } from '@server/models/video/video-password'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) {
VideoJobInfoModel, VideoJobInfoModel,
VideoChannelSyncModel, VideoChannelSyncModel,
UserRegistrationModel, UserRegistrationModel,
VideoPasswordModel,
RunnerRegistrationTokenModel, RunnerRegistrationTokenModel,
RunnerModel, RunnerModel,
RunnerJobModel 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 { getBiggestActorImage } from './actor-image'
import { Hooks } from './plugins/hooks' import { Hooks } from './plugins/hooks'
import { ServerConfigManager } from './server-config-manager' import { ServerConfigManager } from './server-config-manager'
import { isVideoInPrivateDirectory } from './video-privacy'
type Tags = { type Tags = {
ogType: string ogType: string
@ -106,7 +107,7 @@ class ClientHtml {
]) ])
// Let Angular application handle errors // 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) res.status(HttpStatusCode.NOT_FOUND_404)
return html return html
} }

View File

@ -30,6 +30,7 @@ import {
import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
import { getLocalVideoActivityPubUrl } from './activitypub/url' import { getLocalVideoActivityPubUrl } from './activitypub/url'
import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
import { VideoPasswordModel } from '@server/models/video/video-password'
class YoutubeDlImportError extends Error { class YoutubeDlImportError extends Error {
code: YoutubeDlImportError.CODE code: YoutubeDlImportError.CODE
@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
tags: string[] tags: string[]
videoImportAttributes: FilteredModelAttributes<VideoImportModel> videoImportAttributes: FilteredModelAttributes<VideoImportModel>
user: MUser user: MUser
videoPasswords?: string[]
}): Promise<MVideoImportFormattable> { }): 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 videoImport = await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t } const sequelizeOptions = { transaction: t }
@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
}
await autoBlacklistVideoIfNeeded({ await autoBlacklistVideoIfNeeded({
video: videoCreated, video: videoCreated,
user, user,
@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
state: VideoImportState.PENDING, state: VideoImportState.PENDING,
userId: user.id, userId: user.id,
videoChannelSyncId: channelSync?.id videoChannelSyncId: channelSync?.id
} },
videoPasswords: importDataOverride.videoPasswords
}) })
// Get video subtitles // Get video subtitles

View File

@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { VideoPrivacy, VideoStorage } from '@shared/models' import { VideoPrivacy, VideoStorage } from '@shared/models'
import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
const validPrivacySet = new Set([
VideoPrivacy.PRIVATE,
VideoPrivacy.INTERNAL,
VideoPrivacy.PASSWORD_PROTECTED
])
function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
video.publishedAt = new Date() video.publishedAt = new Date()
@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
video.privacy = newPrivacy video.privacy = newPrivacy
} }
function isVideoInPrivateDirectory (privacy: VideoPrivacy) { function isVideoInPrivateDirectory (privacy) {
return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL return validPrivacySet.has(privacy)
} }
function isVideoInPublicDirectory (privacy: VideoPrivacy) { function isVideoInPublicDirectory (privacy: VideoPrivacy) {

View File

@ -12,26 +12,34 @@ class VideoTokensManager {
private static instance: 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, max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
ttl: LRU_CACHE.VIDEO_TOKENS.TTL ttl: LRU_CACHE.VIDEO_TOKENS.TTL
}) })
private constructor () {} private constructor () {}
create (options: { createForAuthUser (options: {
user: MUserAccountUrl user: MUserAccountUrl
videoUUID: string videoUUID: string
}) { }) {
const token = buildUUID() const { token, expires } = this.generateVideoToken()
const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
return { token, expires } return { token, expires }
} }
createForPasswordProtectedVideo (options: {
videoUUID: string
}) {
const { token, expires } = this.generateVideoToken()
this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
return { token, expires }
}
hasToken (options: { hasToken (options: {
token: string token: string
videoUUID: string videoUUID: string
@ -54,6 +62,13 @@ class VideoTokensManager {
static get Instance () { static get Instance () {
return this.instance || (this.instance = new this()) 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 { HttpStatusCode } from '../../shared/models/http/http-error-codes'
import { logger } from '../helpers/logger' import { logger } from '../helpers/logger'
import { handleOAuthAuthenticate } from '../lib/auth/oauth' import { handleOAuthAuthenticate } from '../lib/auth/oauth'
import { ServerErrorCode } from '@shared/models'
function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
handleOAuthAuthenticate(req, res) handleOAuthAuthenticate(req, res)
@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
.catch(err => logger.error('Cannot get access token.', { err })) .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 => { return new Promise<void>(resolve => {
// Already authenticated? (or tried to) // Already authenticated? (or tried to)
if (res.locals.oauth?.token.User) return resolve() if (res.locals.oauth?.token.User) return resolve()
if (res.locals.authenticated === false) { if (res.locals.authenticated === false) {
return res.fail({ return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401, status: errorStatus,
message: 'Not authenticated' type: errorType,
message: errorMessage
}) })
} }

View File

@ -10,4 +10,5 @@ export * from './video-comments'
export * from './video-imports' export * from './video-imports'
export * from './video-ownerships' export * from './video-ownerships'
export * from './video-playlists' export * from './video-playlists'
export * from './video-passwords'
export * from './videos' 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 MVideoWithRights
} from '@server/types/models' } from '@server/types/models'
import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/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') { async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 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 const { req, res, video, paramId } = options
if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
return checkCanSeeAuthVideo(req, res, video) 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) { 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) 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 = () => { const fail = () => {
res.fail({ res.fail({
status: HttpStatusCode.FORBIDDEN_403, status: HttpStatusCode.FORBIDDEN_403,
@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return false return false
} }
await authenticatePromise(req, res) await authenticatePromise({ req, res })
const user = res.locals.oauth?.token.User const user = res.locals.oauth?.token.User
if (!user) return fail() if (!user) return fail()
const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
? video as MVideoWithRights
: await VideoModel.loadFull(video.id)
const privacy = videoWithRights.privacy const privacy = videoWithRights.privacy
@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return true return true
} }
const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
if (videoWithRights.isBlacklisted()) { 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() return fail()
} }
if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { 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() return fail()
} }
@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
return fail() 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: { async function checkCanAccessVideoStaticFiles (options: {
@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
}) { }) {
const { video, req, res } = 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) 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 availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) 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 accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_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 { VideoFileModel } from '@server/models/video/video-file'
import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
import { HttpStatusCode } from '@shared/models' import { HttpStatusCode } from '@shared/models'
import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
type LRUValue = { type LRUValue = {
allowed: boolean allowed: boolean
@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({
const ensureCanAccessVideoPrivateWebTorrentFiles = [ const ensureCanAccessVideoPrivateWebTorrentFiles = [
query('videoFileToken').optional().custom(exists), query('videoFileToken').optional().custom(exists),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
.optional() .optional()
.customSanitizer(isSafePeerTubeFilenameWithoutExtension), .customSanitizer(isSafePeerTubeFilenameWithoutExtension),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return 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) { 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) { if (!token) {
return res.fail({ 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 status: HttpStatusCode.FORBIDDEN_403
}) })
} }

View File

@ -12,6 +12,8 @@ export * from './video-shares'
export * from './video-source' export * from './video-source'
export * from './video-stats' export * from './video-stats'
export * from './video-studio' export * from './video-studio'
export * from './video-token'
export * from './video-transcoding' export * from './video-transcoding'
export * from './videos' export * from './videos'
export * from './video-channel-sync' export * from './video-channel-sync'
export * from './video-passwords'

View File

@ -10,7 +10,8 @@ import {
checkUserCanManageVideo, checkUserCanManageVideo,
doesVideoCaptionExist, doesVideoCaptionExist,
doesVideoExist, doesVideoExist,
isValidVideoIdParam isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared' } from '../shared'
const addVideoCaptionValidator = [ const addVideoCaptionValidator = [
@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
const listVideoCaptionsValidator = [ const listVideoCaptionsValidator = [
isValidVideoIdParam('videoId'), isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return

View File

@ -14,7 +14,8 @@ import {
doesVideoCommentExist, doesVideoCommentExist,
doesVideoCommentThreadExist, doesVideoCommentThreadExist,
doesVideoExist, doesVideoExist,
isValidVideoIdParam isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared' } from '../shared'
const listVideoCommentsValidator = [ const listVideoCommentsValidator = [
@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
const listVideoCommentThreadsValidator = [ const listVideoCommentThreadsValidator = [
isValidVideoIdParam('videoId'), isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
param('threadId') param('threadId')
.custom(isIdValid), .custom(isIdValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
body('text') body('text')
.custom(isValidVideoCommentText), .custom(isValidVideoCommentText),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
isValidVideoIdParam('videoId'), isValidVideoIdParam('videoId'),
param('commentId').custom(isIdValid), param('commentId').custom(isIdValid),
isValidVideoPasswordHeader(),
body('text').custom(isValidVideoCommentText), 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 { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 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 { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config' import { CONFIG } from '../../../initializers/config'
@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
.custom(isVideoNameValid).withMessage( .custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` `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) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
cleanUpReqFiles(req) cleanUpReqFiles(req)

View File

@ -17,7 +17,7 @@ import {
VideoState VideoState
} from '@shared/models' } from '@shared/models'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 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 { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config' import { CONFIG } from '../../../initializers/config'
@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
body('replaySettings.privacy') body('replaySettings.privacy')
.optional() .optional()
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isVideoPrivacyValid), .custom(isVideoReplayPrivacyValid),
body('permanentLive') body('permanentLive')
.optional() .optional()
@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid), .custom(isLiveLatencyModeValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.LIVE.ENABLED !== true) { if (CONFIG.LIVE.ENABLED !== true) {
cleanUpReqFiles(req) cleanUpReqFiles(req)
@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
body('replaySettings.privacy') body('replaySettings.privacy')
.optional() .optional()
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isVideoPrivacyValid), .custom(isVideoReplayPrivacyValid),
body('latencyMode') body('latencyMode')
.optional() .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) { if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
await authenticatePromise(req, res) await authenticatePromise({ req, res })
const user = res.locals.oauth ? res.locals.oauth.token.User : null 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 { isRatingValid } from '../../../helpers/custom-validators/video-rates'
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 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 = [ const videoUpdateRateValidator = [
isValidVideoIdParam('id'), isValidVideoIdParam('id'),
body('rating') body('rating')
.custom(isVideoRatingTypeValid), .custom(isVideoRatingTypeValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return 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 { import {
areVideoTagsValid, areVideoTagsValid,
isScheduleVideoUpdatePrivacyValid, isScheduleVideoUpdatePrivacyValid,
isValidPasswordProtectedPrivacy,
isVideoCategoryValid, isVideoCategoryValid,
isVideoDescriptionValid, isVideoDescriptionValid,
isVideoFileMimeTypeValid, isVideoFileMimeTypeValid,
@ -55,7 +56,8 @@ import {
doesVideoChannelOfAccountExist, doesVideoChannelOfAccountExist,
doesVideoExist, doesVideoExist,
doesVideoFileOfVideoExist, doesVideoFileOfVideoExist,
isValidVideoIdParam isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared' } from '../shared'
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
body('channelId') body('channelId')
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isIdValid), .custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
return cleanUpReqFiles(req) return cleanUpReqFiles(req)
} }
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
try { try {
if (!videoFile.duration) await addDurationToVideo(videoFile) if (!videoFile.duration) await addDurationToVideo(videoFile)
} catch (err) { } catch (err) {
@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
body('channelId') body('channelId')
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isIdValid), .custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
header('x-upload-content-length') header('x-upload-content-length')
.isNumeric() .isNumeric()
@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
const files = { videofile: [ videoFileMetadata ] } const files = { videofile: [ videoFileMetadata ] }
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() 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 // multer required unsetting the Content-Type, now we can set it for node-uploadx
req.headers['content-type'] = 'application/json; charset=utf-8' req.headers['content-type'] = 'application/json; charset=utf-8'
// place previewfile in metadata so that uploadx saves it in .META // place previewfile in metadata so that uploadx saves it in .META
@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
.optional() .optional()
.customSanitizer(toIntOrNull) .customSanitizer(toIntOrNull)
.custom(isIdValid), .custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.id, 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) const video = getVideoWithAttributes(res)
if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { 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' }) 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 [ return [
isValidVideoIdParam('id'), isValidVideoIdParam('id'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, fetchType)) 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? // Internal video?
if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR 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 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 { VideoImportModel } from './video-import'
import { VideoJobInfoModel } from './video-job-info' import { VideoJobInfoModel } from './video-job-info'
import { VideoLiveModel } from './video-live' import { VideoLiveModel } from './video-live'
import { VideoPasswordModel } from './video-password'
import { VideoPlaylistElementModel } from './video-playlist-element' import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share' import { VideoShareModel } from './video-share'
import { VideoSourceModel } from './video-source' import { VideoSourceModel } from './video-source'
@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
}) })
VideoCaptions: VideoCaptionModel[] VideoCaptions: VideoCaptionModel[]
@HasMany(() => VideoPasswordModel, {
foreignKey: {
name: 'videoId',
allowNull: false
},
onDelete: 'cascade'
})
VideoPasswords: VideoPasswordModel[]
@HasOne(() => VideoJobInfoModel, { @HasOne(() => VideoJobInfoModel, {
foreignKey: { foreignKey: {
name: 'videoId', name: 'videoId',
@ -1918,7 +1928,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
requiresAuth (options: { requiresUserAuth (options: {
urlParamId: string urlParamId: string
checkBlacklist: boolean checkBlacklist: boolean
}) { }) {
@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
if (checkBlacklist && this.VideoBlacklist) return true if (checkBlacklist && this.VideoBlacklist) return true
if (this.privacy !== VideoPrivacy.PUBLIC) { if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) return false
} }
return false throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
} }
hasPrivateStaticPath () { 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 () { 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 }) 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 () { 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 }) 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 () { describe('Test video tokens', function () {
let server: PeerTubeServer let server: PeerTubeServer
let videoId: string let privateVideoId: string
let passwordProtectedVideoId: string
let userToken: string let userToken: string
const videoPassword = 'password'
// --------------------------------------------------------------- // ---------------------------------------------------------------
before(async function () { before(async function () {
@ -15,27 +18,50 @@ describe('Test video tokens', function () {
server = await createSingleServer(1) server = await createSingleServer(1)
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
{
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
videoId = uuid 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') userToken = await server.users.generateUserAndToken('user1')
}) })
it('Should not generate tokens for unauthenticated user', async function () { it('Should not generate tokens on private video for unauthenticated user', async function () {
await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
}) })
it('Should not generate tokens of unknown video', async function () { it('Should not generate tokens of unknown video', async function () {
await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 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 () { 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 () { 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 () { after(async function () {

View File

@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () {
describe('VOD', function () { describe('VOD', function () {
let privateVideoUUID: string let privateVideoUUID: string
let publicVideoUUID: string let publicVideoUUID: string
let passwordProtectedVideoUUID: string
let userPrivateVideoUUID: 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) { async function getSampleFileUrls (videoId: string) {
@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
await checkPrivateVODFiles(privateVideoUUID) 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 () { it('Should upload a public video and have appropriate object storage ACL', async function () {
this.timeout(120000) 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 }) 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 () { it('Should not get HLS file of another video', async function () {
this.timeout(60000) 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 }) 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) this.timeout(60000)
const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) 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: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) 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 permanentLiveId: string
let permanentLive: LiveVideo let permanentLive: LiveVideo
let passwordProtectedLiveId: string
let passwordProtectedLive: LiveVideo
const correctPassword = 'my super password'
let unrelatedFileToken: string 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 }) const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
await server.live.waitUntilPublished({ videoId: liveId }) await server.live.waitUntilPublished({ videoId: liveId })
const video = await server.videos.getWithToken({ id: liveId }) const video = videoPassword
const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) ? 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] 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, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
await makeRawRequest({ url, query: { videoFileToken: fileToken }, 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, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, 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) await stopFfmpeg(ffmpegCommand)
@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
permanentLiveId = video.uuid permanentLiveId = video.uuid
permanentLive = live 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 () { 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) 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 () { it('Should reinject video file token in permanent live', async function () {
this.timeout(240000) 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 }) 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.uuid })
await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
}) })
@ -686,7 +686,7 @@ describe('Test video playlists', function () {
await waitJobs(servers) 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) this.timeout(20000)
const name = 'video 89' const name = 'video 89'
@ -702,6 +702,19 @@ describe('Test video playlists', function () {
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) 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 servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
await waitJobs(servers) 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) this.timeout(120000)
for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
await checkPrivateFiles(uuid) 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 () { 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 expectedStatus: HttpStatusCode
token: string token: string
videoFileToken: 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 }) 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.fileUrl, query: { videoFileToken }, expectedStatus })
await makeRawRequest({ url: file.fileDownloadUrl, 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] 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.playlistUrl, query: { videoFileToken }, expectedStatus })
await makeRawRequest({ url: hls.segmentsSha256Url, 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 () { 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 () { it('Should not be able to access a private video files without OAuth token and file token', async function () {
this.timeout(120000) 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 waitJobs([ server ])
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) 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) this.timeout(120000)
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 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 }) 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 () { it('Should reinject video file token', async function () {
this.timeout(120000) this.timeout(120000)
@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
let permanentLiveId: string let permanentLiveId: string
let permanentLive: LiveVideo let permanentLive: LiveVideo
let passwordProtectedLiveId: string
let passwordProtectedLive: LiveVideo
const correctPassword = 'my super password'
let unrelatedFileToken: string 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 }) const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
await server.live.waitUntilPublished({ videoId: liveId }) await server.live.waitUntilPublished({ videoId: liveId })
const video = await server.videos.getWithToken({ id: liveId }) const video = await server.videos.getWithToken({ id: liveId })
const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
const hls = video.streamingPlaylists[0] 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, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, 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) await stopFfmpeg(ffmpegCommand)
@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
permanentLiveId = video.uuid permanentLiveId = video.uuid
permanentLive = live 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 () { it('Should create a private normal live and have a private static path', async function () {
this.timeout(240000) 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 () { it('Should create a private permanent live and have a private static path', async function () {
this.timeout(240000) 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 () { 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 privateVideoId: string
let internalVideoId: string let internalVideoId: string
let unlistedVideoId: string let unlistedVideoId: string
let passwordProtectedVideoId: string
let playlistIds: (string | number)[] = [] 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: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); ({ 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 // 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 basePath of watchVideoBasePaths) {
for (const id of [ privateVideoId, internalVideoId ]) { for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
const res = await makeGetRequest({ const res = await makeGetRequest({
url: servers[0].url, url: servers[0].url,
path: basePath + id, 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('internal')
expect(res.text).to.not.contain('private') 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' }) 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 serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
await waitJobs([ ...servers, serverHLSOnly ]) await waitJobs([ ...servers, serverHLSOnly ])
@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
describe('Video comments feed', function () { 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) { for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) 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