aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorWicklow <123956049+wickloww@users.noreply.github.com>2023-06-29 07:48:55 +0000
committerGitHub <noreply@github.com>2023-06-29 09:48:55 +0200
commit40346ead2b0b7afa475aef057d3673b6c7574b7a (patch)
tree24ffdc23c3a9d987334842e0d400b5bd44500cf7 /client
parentae22c59f14d0d553f60b281948b6c232c2aca178 (diff)
downloadPeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.gz
PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.zst
PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.zip
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
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+admin/overview/videos/video-admin.service.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts2
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html9
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts26
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts20
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html6
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts14
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts10
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html2
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html3
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts8
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html7
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts64
-rw-r--r--client/src/app/core/confirm/confirm.service.ts12
-rw-r--r--client/src/app/modal/confirm.component.html6
-rw-r--r--client/src/app/modal/confirm.component.ts9
-rw-r--r--client/src/app/shared/form-validators/video-validators.ts9
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts8
-rw-r--r--client/src/app/shared/shared-main/video/index.ts1
-rw-r--r--client/src/app/shared/shared-main/video/video-edit.model.ts8
-rw-r--r--client/src/app/shared/shared-main/video/video-file-token.service.ts11
-rw-r--r--client/src/app/shared/shared-main/video/video-password.service.ts29
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts7
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts53
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.html4
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.ts4
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts25
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.ts15
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts3
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html3
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts4
-rw-r--r--client/src/assets/player/shared/manager-options/hls-options-builder.ts17
-rw-r--r--client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts4
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/segment-validator.ts40
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts6
-rw-r--r--client/src/assets/player/types/manager-options.ts4
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts4
-rw-r--r--client/src/root-helpers/video.ts13
-rw-r--r--client/src/standalone/videos/embed.html17
-rw-r--r--client/src/standalone/videos/embed.scss43
-rw-r--r--client/src/standalone/videos/embed.ts44
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts10
-rw-r--r--client/src/standalone/videos/shared/player-html.ts52
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts12
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts24
-rw-r--r--client/src/types/index.ts1
-rw-r--r--client/src/types/server-error.model.ts11
56 files changed, 572 insertions, 143 deletions
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts
index 4b9357fb7..195b265a1 100644
--- a/client/src/app/+admin/overview/videos/video-admin.service.ts
+++ b/client/src/app/+admin/overview/videos/video-admin.service.ts
@@ -151,7 +151,7 @@ export class VideoAdminService {
151 } 151 }
152 152
153 if (filters.excludePublic) { 153 if (filters.excludePublic) {
154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] 154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
155 155
156 filters.excludePublic = undefined 156 filters.excludePublic = undefined
157 } 157 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
index 97ffb6013..393c3ad6b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
30 async disableTwoFactor () { 30 async disableTwoFactor () {
31 const message = $localize`Are you sure you want to disable two factor authentication of your account?` 31 const message = $localize`Are you sure you want to disable two factor authentication of your account?`
32 32
33 const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) 33 const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` })
34 if (confirmed === false) return 34 if (confirmed === false) return
35 35
36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) 36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index b607dabe9..97b713874 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -120,7 +120,12 @@
120 </div> 120 </div>
121 </div> 121 </div>
122 122
123 <div *ngIf="schedulePublicationEnabled" class="form-group"> 123 <div *ngIf="passwordProtectionSelected" class="form-group">
124 <label i18n for="videoPassword">Password</label>
125 <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
126 </div>
127
128 <div *ngIf="schedulePublicationSelected" class="form-group">
124 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> 129 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
125 <p-calendar 130 <p-calendar
126 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" 131 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
@@ -287,7 +292,7 @@
287 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> 292 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
288 <label i18n for="replayPrivacy">Privacy of the new replay</label> 293 <label i18n for="replayPrivacy">Privacy of the new replay</label>
289 <my-select-options 294 <my-select-options
290 labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" 295 labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
291 ></my-select-options> 296 ></my-select-options>
292 </div> 297 </div>
293 298
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 8ed54ce6b..5e5df8db7 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -14,6 +14,7 @@ import {
14 VIDEO_LICENCE_VALIDATOR, 14 VIDEO_LICENCE_VALIDATOR,
15 VIDEO_NAME_VALIDATOR, 15 VIDEO_NAME_VALIDATOR,
16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, 16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
17 VIDEO_PASSWORD_VALIDATOR,
17 VIDEO_PRIVACY_VALIDATOR, 18 VIDEO_PRIVACY_VALIDATOR,
18 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, 19 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
19 VIDEO_SUPPORT_VALIDATOR, 20 VIDEO_SUPPORT_VALIDATOR,
@@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
79 // So that it can be accessed in the template 80 // So that it can be accessed in the template
80 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 81 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
81 82
82 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 83 videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = []
84 replayPrivacies: VideoConstant<VideoPrivacy> [] = []
83 videoCategories: VideoConstant<number>[] = [] 85 videoCategories: VideoConstant<number>[] = []
84 videoLicences: VideoConstant<number>[] = [] 86 videoLicences: VideoConstant<number>[] = []
85 videoLanguages: VideoLanguages[] = [] 87 videoLanguages: VideoLanguages[] = []
@@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
103 105
104 pluginDataFormGroup: FormGroup 106 pluginDataFormGroup: FormGroup
105 107
106 schedulePublicationEnabled = false 108 schedulePublicationSelected = false
109 passwordProtectionSelected = false
107 110
108 calendarLocale: any = {} 111 calendarLocale: any = {}
109 minScheduledDate = new Date() 112 minScheduledDate = new Date()
@@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
148 const obj: { [ id: string ]: BuildFormValidator } = { 151 const obj: { [ id: string ]: BuildFormValidator } = {
149 name: VIDEO_NAME_VALIDATOR, 152 name: VIDEO_NAME_VALIDATOR,
150 privacy: VIDEO_PRIVACY_VALIDATOR, 153 privacy: VIDEO_PRIVACY_VALIDATOR,
154 videoPassword: VIDEO_PASSWORD_VALIDATOR,
151 channelId: VIDEO_CHANNEL_VALIDATOR, 155 channelId: VIDEO_CHANNEL_VALIDATOR,
152 nsfw: null, 156 nsfw: null,
153 commentsEnabled: null, 157 commentsEnabled: null,
@@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
222 226
223 this.serverService.getVideoPrivacies() 227 this.serverService.getVideoPrivacies()
224 .subscribe(privacies => { 228 .subscribe(privacies => {
225 this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies 229 const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
230 this.videoPrivacies = videoPrivacies
231 this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED)
226 232
227 // Can't schedule publication if private privacy is not available (could be deleted by a plugin) 233 // Can't schedule publication if private privacy is not available (could be deleted by a plugin)
228 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) 234 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
@@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
410 .subscribe( 416 .subscribe(
411 newPrivacyId => { 417 newPrivacyId => {
412 418
413 this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY 419 this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
414 420
415 // Value changed 421 // Value changed
416 const scheduleControl = this.form.get('schedulePublicationAt') 422 const scheduleControl = this.form.get('schedulePublicationAt')
417 const waitTranscodingControl = this.form.get('waitTranscoding') 423 const waitTranscodingControl = this.form.get('waitTranscoding')
418 424
419 if (this.schedulePublicationEnabled) { 425 if (this.schedulePublicationSelected) {
420 scheduleControl.setValidators([ Validators.required ]) 426 scheduleControl.setValidators([ Validators.required ])
421 427
422 waitTranscodingControl.disable() 428 waitTranscodingControl.disable()
@@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
437 443
438 this.firstPatchDone = true 444 this.firstPatchDone = true
439 445
446 this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED
447 const videoPasswordControl = this.form.get('videoPassword')
448
449 if (this.passwordProtectionSelected) {
450 videoPasswordControl.setValidators([ Validators.required ])
451 } else {
452 videoPasswordControl.clearValidators()
453 }
454 videoPasswordControl.updateValueAndValidity()
455
440 } 456 }
441 ) 457 )
442 } 458 }
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ad71162b8..629d95c08 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
49 this.buildForm({}) 49 this.buildForm({})
50 50
51 const { videoData } = this.route.snapshot.data 51 const { videoData } = this.route.snapshot.data
52 const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData 52 const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
53 53
54 this.videoDetails = video 54 this.videoDetails = video
55 this.videoEdit = new VideoEdit(this.videoDetails) 55 this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
56 56
57 this.userVideoChannels = videoChannels 57 this.userVideoChannels = videoChannels
58 this.videoCaptions = videoCaptions 58 this.videoCaptions = videoCaptions
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index 6612d22de..2c99b36a8 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -4,8 +4,9 @@ import { Injectable } from '@angular/core'
4import { ActivatedRouteSnapshot } from '@angular/router' 4import { ActivatedRouteSnapshot } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { VideoPrivacy } from '@shared/models/videos'
9 10
10@Injectable() 11@Injectable()
11export class VideoUpdateResolver { 12export class VideoUpdateResolver {
@@ -13,7 +14,8 @@ export class VideoUpdateResolver {
13 private videoService: VideoService, 14 private videoService: VideoService,
14 private liveVideoService: LiveVideoService, 15 private liveVideoService: LiveVideoService,
15 private authService: AuthService, 16 private authService: AuthService,
16 private videoCaptionService: VideoCaptionService 17 private videoCaptionService: VideoCaptionService,
18 private videoPasswordService: VideoPasswordService
17 ) { 19 ) {
18 } 20 }
19 21
@@ -21,11 +23,11 @@ export class VideoUpdateResolver {
21 const uuid: string = route.params['uuid'] 23 const uuid: string = route.params['uuid']
22 24
23 return this.videoService.getVideo({ videoId: uuid }) 25 return this.videoService.getVideo({ videoId: uuid })
24 .pipe( 26 .pipe(
25 switchMap(video => forkJoin(this.buildVideoObservables(video))), 27 switchMap(video => forkJoin(this.buildVideoObservables(video))),
26 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) => 28 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
27 ({ video, videoChannels, videoCaptions, videoSource, liveVideo })) 29 ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
28 ) 30 )
29 } 31 }
30 32
31 private buildVideoObservables (video: VideoDetails) { 33 private buildVideoObservables (video: VideoDetails) {
@@ -46,6 +48,10 @@ export class VideoUpdateResolver {
46 48
47 video.isLive 49 video.isLive
48 ? this.liveVideoService.getVideoLive(video.id) 50 ? this.liveVideoService.getVideoLive(video.id)
51 : of(undefined),
52
53 video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
49 : of(undefined) 55 : of(undefined)
50 ] 56 ]
51 } 57 }
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
index cf32e371a..140a391e9 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
@@ -1,7 +1,7 @@
1<div class="video-actions-rates"> 1<div class="video-actions-rates">
2 <div class="video-actions justify-content-end"> 2 <div class="video-actions justify-content-end">
3 <my-video-rate 3 <my-video-rate
4 [video]="video" [isUserLoggedIn]="isUserLoggedIn" 4 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" 5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
6 ></my-video-rate> 6 ></my-video-rate>
7 7
@@ -20,7 +20,7 @@
20 20
21 <div 21 <div
22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" 22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
23 *ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)" 23 *ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
24 [ngbTooltip]="tooltipSaveToPlaylist" 24 [ngbTooltip]="tooltipSaveToPlaylist"
25 placement="bottom auto" 25 placement="bottom auto"
26 > 26 >
@@ -43,7 +43,7 @@
43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> 43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
44 </button> 44 </button>
45 45
46 <my-video-download #videoDownloadModal></my-video-download> 46 <my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
47 </ng-container> 47 </ng-container>
48 48
49 <ng-container *ngIf="isUserLoggedIn"> 49 <ng-container *ngIf="isUserLoggedIn">
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index 51718827d..e6c0d4de1 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal'
5import { SupportModalComponent } from '@app/shared/shared-support-modal' 5import { SupportModalComponent } from '@app/shared/shared-support-modal'
6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' 6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
7import { VideoPlaylist } from '@app/shared/shared-video-playlist' 7import { VideoPlaylist } from '@app/shared/shared-video-playlist'
8import { UserVideoRateType, VideoCaption } from '@shared/models/videos' 8import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
9 9
10@Component({ 10@Component({
11 selector: 'my-action-buttons', 11 selector: 'my-action-buttons',
@@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent 18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
19 19
20 @Input() video: VideoDetails 20 @Input() video: VideoDetails
21 @Input() videoPassword: string
21 @Input() videoCaptions: VideoCaption[] 22 @Input() videoCaptions: VideoCaption[]
22 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
23 24
24 @Input() isUserLoggedIn: boolean 25 @Input() isUserLoggedIn: boolean
26 @Input() isUserOwner: boolean
25 27
26 @Input() currentTime: number 28 @Input() currentTime: number
27 @Input() currentPlaylistPosition: number 29 @Input() currentPlaylistPosition: number
@@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
92 private setVideoLikesBarTooltipText () { 94 private setVideoLikesBarTooltipText () {
93 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` 95 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
94 } 96 }
97
98 isVideoAddableToPlaylist () {
99 const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
100
101 if (!this.isUserLoggedIn) return false
102
103 if (isPasswordProtected) return this.isUserOwner
104
105 return true
106 }
95} 107}
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
index d0c138834..11966ce34 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
@@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models'
12}) 12})
13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { 13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
14 @Input() video: VideoDetails 14 @Input() video: VideoDetails
15 @Input() videoPassword: string
15 @Input() isUserLoggedIn: boolean 16 @Input() isUserLoggedIn: boolean
16 17
17 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() 18 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
@@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
103 } 104 }
104 105
105 private setRating (nextRating: UserVideoRateType) { 106 private setRating (nextRating: UserVideoRateType) {
106 const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = { 107 const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = {
107 like: this.videoService.setVideoLike, 108 like: this.videoService.setVideoLike,
108 dislike: this.videoService.setVideoDislike, 109 dislike: this.videoService.setVideoDislike,
109 none: this.videoService.unsetVideoLike 110 none: this.videoService.unsetVideoLike
110 } 111 }
111 112
112 ratingMethods[nextRating].call(this.videoService, this.video.uuid) 113 ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
113 .subscribe({ 114 .subscribe({
114 next: () => { 115 next: () => {
115 // Update the video like attribute 116 // Update the video like attribute
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
index 033097084..1d9e10d0a 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
@@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models'
29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { 29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
30 @Input() user: User 30 @Input() user: User
31 @Input() video: Video 31 @Input() video: Video
32 @Input() videoPassword: string
32 @Input() parentComment?: VideoComment 33 @Input() parentComment?: VideoComment
33 @Input() parentComments?: VideoComment[] 34 @Input() parentComments?: VideoComment[]
34 @Input() focusOnInit = false 35 @Input() focusOnInit = false
@@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
176 177
177 private addCommentReply (commentCreate: VideoCommentCreate) { 178 private addCommentReply (commentCreate: VideoCommentCreate) {
178 return this.videoCommentService 179 return this.videoCommentService
179 .addCommentReply(this.video.uuid, this.parentComment.id, commentCreate) 180 .addCommentReply({
181 videoId: this.video.uuid,
182 inReplyToCommentId: this.parentComment.id,
183 comment: commentCreate,
184 videoPassword: this.videoPassword
185 })
180 } 186 }
181 187
182 private addCommentThread (commentCreate: VideoCommentCreate) { 188 private addCommentThread (commentCreate: VideoCommentCreate) {
183 return this.videoCommentService 189 return this.videoCommentService
184 .addCommentThread(this.video.uuid, commentCreate) 190 .addCommentThread(this.video.uuid, commentCreate, this.videoPassword)
185 } 191 }
186 192
187 private initTextValue () { 193 private initTextValue () {
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
index 91bd8309c..80ea22a20 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -62,6 +62,7 @@
62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" 62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
63 [user]="user" 63 [user]="user"
64 [video]="video" 64 [video]="video"
65 [videoPassword]="videoPassword"
65 [parentComment]="comment" 66 [parentComment]="comment"
66 [parentComments]="newParentComments" 67 [parentComments]="newParentComments"
67 [focusOnInit]="true" 68 [focusOnInit]="true"
@@ -75,6 +76,7 @@
75 <my-video-comment 76 <my-video-comment
76 [comment]="commentChild.comment" 77 [comment]="commentChild.comment"
77 [video]="video" 78 [video]="video"
79 [videoPassword]="videoPassword"
78 [inReplyToCommentId]="inReplyToCommentId" 80 [inReplyToCommentId]="inReplyToCommentId"
79 [commentTree]="commentChild" 81 [commentTree]="commentChild"
80 [parentComments]="newParentComments" 82 [parentComments]="newParentComments"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
index 191ec4a28..4c85df657 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
@@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent 16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
17 17
18 @Input() video: Video 18 @Input() video: Video
19 @Input() videoPassword: string
19 @Input() comment: VideoComment 20 @Input() comment: VideoComment
20 @Input() parentComments: VideoComment[] = [] 21 @Input() parentComments: VideoComment[] = []
21 @Input() commentTree: VideoCommentThreadTree 22 @Input() commentTree: VideoCommentThreadTree
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
index a003a10eb..0932d2b7f 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
@@ -20,6 +20,7 @@
20 <ng-template [ngIf]="video.commentsEnabled === true"> 20 <ng-template [ngIf]="video.commentsEnabled === true">
21 <my-video-comment-add 21 <my-video-comment-add
22 [video]="video" 22 [video]="video"
23 [videoPassword]="videoPassword"
23 [user]="user" 24 [user]="user"
24 (commentCreated)="onCommentThreadCreated($event)" 25 (commentCreated)="onCommentThreadCreated($event)"
25 [textValue]="commentThreadRedraftValue" 26 [textValue]="commentThreadRedraftValue"
@@ -34,6 +35,7 @@
34 *ngIf="highlightedThread" 35 *ngIf="highlightedThread"
35 [comment]="highlightedThread" 36 [comment]="highlightedThread"
36 [video]="video" 37 [video]="video"
38 [videoPassword]="videoPassword"
37 [inReplyToCommentId]="inReplyToCommentId" 39 [inReplyToCommentId]="inReplyToCommentId"
38 [commentTree]="threadComments[highlightedThread.id]" 40 [commentTree]="threadComments[highlightedThread.id]"
39 [highlightedComment]="true" 41 [highlightedComment]="true"
@@ -53,6 +55,7 @@
53 *ngIf="!highlightedThread || comment.id !== highlightedThread.id" 55 *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
54 [comment]="comment" 56 [comment]="comment"
55 [video]="video" 57 [video]="video"
58 [videoPassword]="videoPassword"
56 [inReplyToCommentId]="inReplyToCommentId" 59 [inReplyToCommentId]="inReplyToCommentId"
57 [commentTree]="threadComments[comment.id]" 60 [commentTree]="threadComments[comment.id]"
58 [firstInThread]="i + 1 !== comments.length" 61 [firstInThread]="i + 1 !== comments.length"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
index 96bdb28c9..848936f91 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
@@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { 15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef 16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
17 @Input() video: VideoDetails 17 @Input() video: VideoDetails
18 @Input() videoPassword: string
18 @Input() user: User 19 @Input() user: User
19 20
20 @Output() timestampClicked = new EventEmitter<number>() 21 @Output() timestampClicked = new EventEmitter<number>()
@@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
80 81
81 const params = { 82 const params = {
82 videoId: this.video.uuid, 83 videoId: this.video.uuid,
83 threadId: commentId 84 threadId: commentId,
85 videoPassword: this.videoPassword
84 } 86 }
85 87
86 const obs = this.hooks.wrapObsFun( 88 const obs = this.hooks.wrapObsFun(
@@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
119 loadMoreThreads () { 121 loadMoreThreads () {
120 const params = { 122 const params = {
121 videoId: this.video.uuid, 123 videoId: this.video.uuid,
124 videoPassword: this.videoPassword,
122 componentPagination: this.componentPagination, 125 componentPagination: this.componentPagination,
123 sort: this.sort 126 sort: this.sort
124 } 127 }
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 79b83811d..45e222743 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -42,3 +42,7 @@
42 <div class="blocked-label" i18n>This video is blocked.</div> 42 <div class="blocked-label" i18n>This video is blocked.</div>
43 {{ video.blacklistedReason }} 43 {{ video.blacklistedReason }}
44</div> 44</div>
45
46<div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)">
47 This video is password protected.
48</div>
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index ba79fabc8..8781ead7e 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -1,6 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { AuthUser } from '@app/core'
2import { VideoDetails } from '@app/shared/shared-main' 3import { VideoDetails } from '@app/shared/shared-main'
3import { VideoState } from '@shared/models' 4import { VideoPrivacy, VideoState } from '@shared/models'
4 5
5@Component({ 6@Component({
6 selector: 'my-video-alert', 7 selector: 'my-video-alert',
@@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
8 styleUrls: [ './video-alert.component.scss' ] 9 styleUrls: [ './video-alert.component.scss' ]
9}) 10})
10export class VideoAlertComponent { 11export class VideoAlertComponent {
12 @Input() user: AuthUser
11 @Input() video: VideoDetails 13 @Input() video: VideoDetails
12 @Input() noPlaylistVideoFound: boolean 14 @Input() noPlaylistVideoFound: boolean
13 15
@@ -46,4 +48,8 @@ export class VideoAlertComponent {
46 isLiveEnded () { 48 isLiveEnded () {
47 return this.video?.state.id === VideoState.LIVE_ENDED 49 return this.video?.state.id === VideoState.LIVE_ENDED
48 } 50 }
51
52 isVideoPasswordProtected () {
53 return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 }
49} 55}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
index 461891779..80fd6e40f 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -19,7 +19,7 @@
19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> 19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
20 </div> 20 </div>
21 21
22 <my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert> 22 <my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
23 23
24 <!-- Video information --> 24 <!-- Video information -->
25 <div *ngIf="video" class="margin-content video-bottom"> 25 <div *ngIf="video" class="margin-content video-bottom">
@@ -51,8 +51,8 @@
51 </div> 51 </div>
52 52
53 <my-action-buttons 53 <my-action-buttons
54 [video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist" 54 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
55 [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" 55 [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
56 ></my-action-buttons> 56 ></my-action-buttons>
57 </div> 57 </div>
58 </div> 58 </div>
@@ -92,6 +92,7 @@
92 <my-video-comments 92 <my-video-comments
93 class="border-top" 93 class="border-top"
94 [video]="video" 94 [video]="video"
95 [videoPassword]="videoPassword"
95 [user]="user" 96 [user]="user"
96 (timestampClicked)="handleTimestampClicked($event)" 97 (timestampClicked)="handleTimestampClicked($event)"
97 ></my-video-comments> 98 ></my-video-comments>
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 19ad97d42..aba3ee086 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -25,7 +25,7 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
25import { LiveVideoService } from '@app/shared/shared-video-live' 25import { LiveVideoService } from '@app/shared/shared-video-live'
26import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 26import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
27import { logger } from '@root-helpers/logger' 27import { logger } from '@root-helpers/logger'
28import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' 28import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video'
29import { timeToInt } from '@shared/core-utils' 29import { timeToInt } from '@shared/core-utils'
30import { 30import {
31 HTMLServerConfig, 31 HTMLServerConfig,
@@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
68 video: VideoDetails = null 68 video: VideoDetails = null
69 videoCaptions: VideoCaption[] = [] 69 videoCaptions: VideoCaption[] = []
70 liveVideo: LiveVideo 70 liveVideo: LiveVideo
71 videoPassword: string
71 72
72 playlistPosition: number 73 playlistPosition: number
73 playlist: VideoPlaylist = null 74 playlist: VideoPlaylist = null
@@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
191 return this.authService.isLoggedIn() 192 return this.authService.isLoggedIn()
192 } 193 }
193 194
195 isUserOwner () {
196 return this.video.isLocal === true && this.video.account.name === this.user?.username
197 }
198
194 isVideoBlur (video: Video) { 199 isVideoBlur (video: Video) {
195 return video.isVideoNSFWForUser(this.user, this.serverConfig) 200 return video.isVideoNSFWForUser(this.user, this.serverConfig)
196 } 201 }
@@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
243 private loadVideo (options: { 248 private loadVideo (options: {
244 videoId: string 249 videoId: string
245 forceAutoplay: boolean 250 forceAutoplay: boolean
251 videoPassword?: string
246 }) { 252 }) {
247 const { videoId, forceAutoplay } = options 253 const { videoId, forceAutoplay, videoPassword } = options
248 254
249 if (this.isSameElement(this.video, videoId)) return 255 if (this.isSameElement(this.video, videoId)) return
250 256
@@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
254 260
255 const videoObs = this.hooks.wrapObsFun( 261 const videoObs = this.hooks.wrapObsFun(
256 this.videoService.getVideo.bind(this.videoService), 262 this.videoService.getVideo.bind(this.videoService),
257 { videoId }, 263 { videoId, videoPassword },
258 'video-watch', 264 'video-watch',
259 'filter:api.video-watch.video.get.params', 265 'filter:api.video-watch.video.get.params',
260 'filter:api.video-watch.video.get.result' 266 'filter:api.video-watch.video.get.result'
@@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
269 }), 275 }),
270 276
271 switchMap(({ video, live }) => { 277 switchMap(({ video, live }) => {
272 if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) 278 if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined })
273 279
274 return this.videoFileTokenService.getVideoFileToken(video.uuid) 280 return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword })
275 .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) 281 .pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
276 }) 282 })
277 ) 283 )
278 284
279 forkJoin([ 285 forkJoin([
280 videoAndLiveObs, 286 videoAndLiveObs,
281 this.videoCaptionService.listCaptions(videoId), 287 this.videoCaptionService.listCaptions(videoId, videoPassword),
282 this.userService.getAnonymousOrLoggedUser() 288 this.userService.getAnonymousOrLoggedUser()
283 ]).subscribe({ 289 ]).subscribe({
284 next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { 290 next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
@@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
304 live, 310 live,
305 videoCaptions: captionsResult.data, 311 videoCaptions: captionsResult.data,
306 videoFileToken, 312 videoFileToken,
313 videoPassword,
307 loggedInOrAnonymousUser, 314 loggedInOrAnonymousUser,
308 urlOptions, 315 urlOptions,
309 forceAutoplay 316 forceAutoplay
310 }).catch(err => this.handleGlobalError(err)) 317 }).catch(err => {
318 this.handleGlobalError(err)
319 })
311 }, 320 },
321 error: async err => {
322 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
323 const { confirmed, password } = await this.handleVideoPasswordError(err)
324
325 if (confirmed === false) return this.location.back()
312 326
313 error: err => this.handleRequestError(err) 327 this.loadVideo({ ...options, videoPassword: password })
328 } else {
329 this.handleRequestError(err)
330 }
331 }
314 }) 332 })
315 } 333 }
316 334
@@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
375 this.notifier.error(errorMessage) 393 this.notifier.error(errorMessage)
376 } 394 }
377 395
396 private handleVideoPasswordError (err: any) {
397 let isIncorrectPassword: boolean
398
399 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) {
400 isIncorrectPassword = false
401 } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
402 this.videoPassword = undefined
403 isIncorrectPassword = true
404 }
405
406 return this.confirmService.confirmWithPassword({
407 message: $localize`You need a password to watch this video`,
408 title: $localize`This video is password protected`,
409 errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : ''
410 })
411 }
412
378 private async onVideoFetched (options: { 413 private async onVideoFetched (options: {
379 video: VideoDetails 414 video: VideoDetails
380 live: LiveVideo 415 live: LiveVideo
381 videoCaptions: VideoCaption[] 416 videoCaptions: VideoCaption[]
382 videoFileToken: string 417 videoFileToken: string
418 videoPassword: string
383 419
384 urlOptions: URLOptions 420 urlOptions: URLOptions
385 loggedInOrAnonymousUser: User 421 loggedInOrAnonymousUser: User
386 forceAutoplay: boolean 422 forceAutoplay: boolean
387 }) { 423 }) {
388 const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options 424 const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options
389 425
390 this.subscribeToLiveEventsIfNeeded(this.video, video) 426 this.subscribeToLiveEventsIfNeeded(this.video, video)
391 427
@@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
393 this.videoCaptions = videoCaptions 429 this.videoCaptions = videoCaptions
394 this.liveVideo = live 430 this.liveVideo = live
395 this.videoFileToken = videoFileToken 431 this.videoFileToken = videoFileToken
432 this.videoPassword = videoPassword
396 433
397 // Re init attributes 434 // Re init attributes
398 this.playerPlaceholderImgSrc = undefined 435 this.playerPlaceholderImgSrc = undefined
@@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
450 videoCaptions: this.videoCaptions, 487 videoCaptions: this.videoCaptions,
451 liveVideo: this.liveVideo, 488 liveVideo: this.liveVideo,
452 videoFileToken: this.videoFileToken, 489 videoFileToken: this.videoFileToken,
490 videoPassword: this.videoPassword,
453 urlOptions, 491 urlOptions,
454 loggedInOrAnonymousUser, 492 loggedInOrAnonymousUser,
455 forceAutoplay, 493 forceAutoplay,
@@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
600 videoCaptions: VideoCaption[] 638 videoCaptions: VideoCaption[]
601 639
602 videoFileToken: string 640 videoFileToken: string
641 videoPassword: string
603 642
604 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 643 urlOptions: CustomizationOptions & { playerMode: PlayerMode }
605 644
@@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
607 forceAutoplay: boolean 646 forceAutoplay: boolean
608 user?: AuthUser // Keep for plugins 647 user?: AuthUser // Keep for plugins
609 }) { 648 }) {
610 const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params 649 const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
611 650
612 const getStartTime = () => { 651 const getStartTime = () => {
613 const byUrl = urlOptions.startTime !== undefined 652 const byUrl = urlOptions.startTime !== undefined
@@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
689 serverUrl: environment.originServerUrl || window.location.origin, 728 serverUrl: environment.originServerUrl || window.location.origin,
690 729
691 videoFileToken: () => videoFileToken, 730 videoFileToken: () => videoFileToken,
692 requiresAuth: videoRequiresAuth(video), 731 requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
732 requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
733 !video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
734 videoPassword: () => videoPassword,
693 735
694 videoCaptions: playerCaptions, 736 videoCaptions: playerCaptions,
695 737
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 89a25f0a5..abe163aae 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4type ConfirmOptions = { 4type ConfirmOptions = {
5 title: string 5 title: string
6 message: string 6 message: string
7 errorMessage?: string
7} & ( 8} & (
8 { 9 {
9 type: 'confirm' 10 type: 'confirm'
@@ -12,6 +13,7 @@ type ConfirmOptions = {
12 { 13 {
13 type: 'confirm-password' 14 type: 'confirm-password'
14 confirmButtonText?: string 15 confirmButtonText?: string
16 isIncorrectPassword?: boolean
15 } | 17 } |
16 { 18 {
17 type: 'confirm-expected-input' 19 type: 'confirm-expected-input'
@@ -32,8 +34,14 @@ export class ConfirmService {
32 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) 34 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
33 } 35 }
34 36
35 confirmWithPassword (message: string, title = '', confirmButtonText?: string) { 37 confirmWithPassword (options: {
36 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) 38 message: string
39 title?: string
40 confirmButtonText?: string
41 errorMessage?: string
42 }) {
43 const { message, title = '', confirmButtonText, errorMessage } = options
44 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage })
37 45
38 const obs = this.confirmResponse.asObservable() 46 const obs = this.confirmResponse.asObservable()
39 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) 47 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index 6584db3e6..33696d0a5 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -12,10 +12,12 @@
12 <div *ngIf="inputLabel" class="form-group mt-3"> 12 <div *ngIf="inputLabel" class="form-group mt-3">
13 <label for="confirmInput">{{ inputLabel }}</label> 13 <label for="confirmInput">{{ inputLabel }}</label>
14 14
15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> 15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" />
16 16
17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text> 17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text>
18 </div> 18 </div>
19
20 <div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
19 </div> 21 </div>
20 22
21 <div class="modal-footer inputs"> 23 <div class="modal-footer inputs">
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index 3bb8b9b21..43369befa 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
21 inputValue = '' 21 inputValue = ''
22 confirmButtonText = '' 22 confirmButtonText = ''
23 23
24 errorMessage = ''
25
24 isPasswordInput = false 26 isPasswordInput = false
25 27
26 private openedModal: NgbModalRef 28 private openedModal: NgbModalRef
@@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit {
42 this.inputValue = '' 44 this.inputValue = ''
43 this.confirmButtonText = '' 45 this.confirmButtonText = ''
44 this.isPasswordInput = false 46 this.isPasswordInput = false
47 this.errorMessage = ''
45 48
46 const { type, title, message, confirmButtonText } = payload 49 const { type, title, message, confirmButtonText, errorMessage } = payload
47 50
48 this.title = title 51 this.title = title
49 52
@@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit {
53 } else if (type === 'confirm-password') { 56 } else if (type === 'confirm-password') {
54 this.inputLabel = $localize`Confirm your password` 57 this.inputLabel = $localize`Confirm your password`
55 this.isPasswordInput = true 58 this.isPasswordInput = true
59 this.errorMessage = errorMessage
56 } 60 }
57 61
58 this.confirmButtonText = confirmButtonText || $localize`Confirm` 62 this.confirmButtonText = confirmButtonText || $localize`Confirm`
@@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit {
78 return this.expectedInputValue !== this.inputValue 82 return this.expectedInputValue !== this.inputValue
79 } 83 }
80 84
85 hasError () {
86 return this.errorMessage
87 }
81 showModal () { 88 showModal () {
82 this.inputValue = '' 89 this.inputValue = ''
83 90
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts
index a4bda8f16..090a76e43 100644
--- a/client/src/app/shared/form-validators/video-validators.ts
+++ b/client/src/app/shared/form-validators/video-validators.ts
@@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = {
26 } 26 }
27} 27}
28 28
29export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
31 MESSAGES: {
32 minLength: $localize`A password should be at least 2 characters long.`,
33 maxLength: $localize`A password should be shorter than 100 characters long.`,
34 required: $localize`A password is required for password protected video.`
35 }
36}
37
29export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { 38export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ ], 39 VALIDATORS: [ ],
31 MESSAGES: {} 40 MESSAGES: {}
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index d3ec31d6e..480277450 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -52,6 +52,7 @@ import {
52 VideoFileTokenService, 52 VideoFileTokenService,
53 VideoImportService, 53 VideoImportService,
54 VideoOwnershipService, 54 VideoOwnershipService,
55 VideoPasswordService,
55 VideoResolver, 56 VideoResolver,
56 VideoService 57 VideoService
57} from './video' 58} from './video'
@@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel'
210 211
211 VideoChannelService, 212 VideoChannelService,
212 213
214 VideoPasswordService,
215
213 CustomPageService, 216 CustomPageService,
214 217
215 ActorRedirectGuard 218 ActorRedirectGuard
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
index 0f3afd116..21f31a717 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core' 5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers' 6import { objectToFormData, sortBy } from '@app/helpers'
7import { VideoService } from '@app/shared/shared-main/video' 7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@shared/core-utils/i18n' 8import { peertubeTranslate } from '@shared/core-utils/i18n'
9import { ResultList, VideoCaption } from '@shared/models' 9import { ResultList, VideoCaption } from '@shared/models'
10import { environment } from '../../../../environments/environment' 10import { environment } from '../../../../environments/environment'
@@ -18,8 +18,10 @@ export class VideoCaptionService {
18 private restExtractor: RestExtractor 18 private restExtractor: RestExtractor
19 ) {} 19 ) {}
20 20
21 listCaptions (videoId: string): Observable<ResultList<VideoCaption>> { 21 listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> {
22 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`) 22 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
23
24 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers })
23 .pipe( 25 .pipe(
24 switchMap(captionsResult => { 26 switchMap(captionsResult => {
25 return this.serverService.getServerLocale() 27 return this.serverService.getServerLocale()
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index a2e47883e..07d40b117 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -5,6 +5,7 @@ export * from './video-edit.model'
5export * from './video-file-token.service' 5export * from './video-file-token.service'
6export * from './video-import.service' 6export * from './video-import.service'
7export * from './video-ownership.service' 7export * from './video-ownership.service'
8export * from './video-password.service'
8export * from './video.model' 9export * from './video.model'
9export * from './video.resolver' 10export * from './video.resolver'
10export * from './video.service' 11export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts
index 47eee80d8..1b8b67ee2 100644
--- a/client/src/app/shared/shared-main/video/video-edit.model.ts
+++ b/client/src/app/shared/shared-main/video/video-edit.model.ts
@@ -1,5 +1,5 @@
1import { getAbsoluteAPIUrl } from '@app/helpers' 1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' 2import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
3import { VideoDetails } from './video-details.model' 3import { VideoDetails } from './video-details.model'
4import { objectKeysTyped } from '@shared/core-utils' 4import { objectKeysTyped } from '@shared/core-utils'
5 5
@@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate {
18 waitTranscoding: boolean 18 waitTranscoding: boolean
19 channelId: number 19 channelId: number
20 privacy: VideoPrivacy 20 privacy: VideoPrivacy
21 videoPassword?: string
21 support: string 22 support: string
22 thumbnailfile?: any 23 thumbnailfile?: any
23 previewfile?: any 24 previewfile?: any
@@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate {
32 33
33 pluginData?: any 34 pluginData?: any
34 35
35 constructor (video?: VideoDetails) { 36 constructor (video?: VideoDetails, videoPassword?: VideoPassword) {
36 if (!video) return 37 if (!video) return
37 38
38 this.id = video.id 39 this.id = video.id
@@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate {
63 : null 64 : null
64 65
65 this.pluginData = video.pluginData 66 this.pluginData = video.pluginData
67
68 if (videoPassword) this.videoPassword = videoPassword.password
66 } 69 }
67 70
68 patch (values: { [ id: string ]: any }) { 71 patch (values: { [ id: string ]: any }) {
@@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate {
112 waitTranscoding: this.waitTranscoding, 115 waitTranscoding: this.waitTranscoding,
113 channelId: this.channelId, 116 channelId: this.channelId,
114 privacy: this.privacy, 117 privacy: this.privacy,
118 videoPassword: this.videoPassword,
115 originallyPublishedAt: this.originallyPublishedAt 119 originallyPublishedAt: this.originallyPublishedAt
116 } 120 }
117 121
diff --git a/client/src/app/shared/shared-main/video/video-file-token.service.ts b/client/src/app/shared/shared-main/video/video-file-token.service.ts
index 791607249..9bca5b9ec 100644
--- a/client/src/app/shared/shared-main/video/video-file-token.service.ts
+++ b/client/src/app/shared/shared-main/video/video-file-token.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { VideoToken } from '@shared/models' 5import { VideoToken } from '@shared/models'
6import { VideoService } from './video.service' 6import { VideoService } from './video.service'
7import { VideoPasswordService } from './video-password.service'
7 8
8@Injectable() 9@Injectable()
9export class VideoFileTokenService { 10export class VideoFileTokenService {
@@ -15,16 +16,18 @@ export class VideoFileTokenService {
15 private restExtractor: RestExtractor 16 private restExtractor: RestExtractor
16 ) {} 17 ) {}
17 18
18 getVideoFileToken (videoUUID: string) { 19 getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
19 const existing = this.store.get(videoUUID) 20 const existing = this.store.get(videoUUID)
20 if (existing) return of(existing) 21 if (existing) return of(existing)
21 22
22 return this.createVideoFileToken(videoUUID) 23 return this.createVideoFileToken(videoUUID, videoPassword)
23 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) 24 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
24 } 25 }
25 26
26 private createVideoFileToken (videoUUID: string) { 27 private createVideoFileToken (videoUUID: string, videoPassword?: string) {
27 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) 28 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
29
30 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
28 .pipe( 31 .pipe(
29 map(({ files }) => files), 32 map(({ files }) => files),
30 catchError(err => this.restExtractor.handleError(err)) 33 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-main/video/video-password.service.ts b/client/src/app/shared/shared-main/video/video-password.service.ts
new file mode 100644
index 000000000..d5b0406f8
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-password.service.ts
@@ -0,0 +1,29 @@
1import { ResultList, VideoPassword } from '@shared/models'
2import { Injectable } from '@angular/core'
3import { catchError, switchMap } from 'rxjs'
4import { HttpClient, HttpHeaders } from '@angular/common/http'
5import { RestExtractor } from '@app/core'
6import { VideoService } from './video.service'
7
8@Injectable()
9export class VideoPasswordService {
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) {}
15
16 static buildVideoPasswordHeader (videoPassword: string) {
17 return videoPassword
18 ? new HttpHeaders().set('x-peertube-video-password', videoPassword)
19 : undefined
20 }
21
22 getVideoPasswords (options: { videoUUID: string }) {
23 return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`)
24 .pipe(
25 switchMap(res => res.data),
26 catchError(err => this.restExtractor.handleError(err))
27 )
28 }
29}
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index 6fdffb394..24c00c3d5 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -281,6 +281,13 @@ export class Video implements VideoServerModel {
281 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) 281 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
282 } 282 }
283 283
284 canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) {
285 return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
286 user &&
287 this.isLocal === true &&
288 (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
289 }
290
284 getExactNumberOfViews () { 291 getExactNumberOfViews () {
285 if (this.isLive) { 292 if (this.isLive) {
286 return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) 293 return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 78a49567f..d67a2e192 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -33,6 +33,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel'
33import { VideoDetails } from './video-details.model' 33import { VideoDetails } from './video-details.model'
34import { VideoEdit } from './video-edit.model' 34import { VideoEdit } from './video-edit.model'
35import { Video } from './video.model' 35import { Video } from './video.model'
36import { VideoPasswordService } from './video-password.service'
36 37
37export type CommonVideoParams = { 38export type CommonVideoParams = {
38 videoPagination?: ComponentPaginationLight 39 videoPagination?: ComponentPaginationLight
@@ -69,16 +70,17 @@ export class VideoService {
69 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` 70 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
70 } 71 }
71 72
72 getVideo (options: { videoId: string }): Observable<VideoDetails> { 73 getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
73 return this.serverService.getServerLocale() 74 const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
74 .pipe( 75
75 switchMap(translations => { 76 return this.serverService.getServerLocale().pipe(
76 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`) 77 switchMap(translations => {
77 .pipe(map(videoHash => ({ videoHash, translations }))) 78 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers })
78 }), 79 .pipe(map(videoHash => ({ videoHash, translations })))
79 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), 80 }),
80 catchError(err => this.restExtractor.handleError(err)) 81 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
81 ) 82 catchError(err => this.restExtractor.handleError(err))
83 )
82 } 84 }
83 85
84 updateVideo (video: VideoEdit) { 86 updateVideo (video: VideoEdit) {
@@ -99,6 +101,9 @@ export class VideoService {
99 description, 101 description,
100 channelId: video.channelId, 102 channelId: video.channelId,
101 privacy: video.privacy, 103 privacy: video.privacy,
104 videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
105 ? [ video.videoPassword ]
106 : undefined,
102 tags: video.tags, 107 tags: video.tags,
103 nsfw: video.nsfw, 108 nsfw: video.nsfw,
104 waitTranscoding: video.waitTranscoding, 109 waitTranscoding: video.waitTranscoding,
@@ -353,16 +358,16 @@ export class VideoService {
353 ) 358 )
354 } 359 }
355 360
356 setVideoLike (id: string) { 361 setVideoLike (id: string, videoPassword: string) {
357 return this.setVideoRate(id, 'like') 362 return this.setVideoRate(id, 'like', videoPassword)
358 } 363 }
359 364
360 setVideoDislike (id: string) { 365 setVideoDislike (id: string, videoPassword: string) {
361 return this.setVideoRate(id, 'dislike') 366 return this.setVideoRate(id, 'dislike', videoPassword)
362 } 367 }
363 368
364 unsetVideoLike (id: string) { 369 unsetVideoLike (id: string, videoPassword: string) {
365 return this.setVideoRate(id, 'none') 370 return this.setVideoRate(id, 'none', videoPassword)
366 } 371 }
367 372
368 getUserVideoRating (id: string) { 373 getUserVideoRating (id: string) {
@@ -394,7 +399,8 @@ export class VideoService {
394 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, 399 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
395 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, 400 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
396 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, 401 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
397 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video` 402 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`,
403 [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`
398 } 404 }
399 405
400 const videoPrivacies = serverPrivacies.map(p => { 406 const videoPrivacies = serverPrivacies.map(p => {
@@ -412,7 +418,13 @@ export class VideoService {
412 } 418 }
413 419
414 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { 420 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
415 const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ] 421 // We do not add a password as this requires additional configuration.
422 const order = [
423 VideoPrivacy.PRIVATE,
424 VideoPrivacy.INTERNAL,
425 VideoPrivacy.UNLISTED,
426 VideoPrivacy.PUBLIC
427 ]
416 428
417 for (const privacy of order) { 429 for (const privacy of order) {
418 if (serverPrivacies.find(p => p.id === privacy)) { 430 if (serverPrivacies.find(p => p.id === privacy)) {
@@ -499,14 +511,15 @@ export class VideoService {
499 } 511 }
500 } 512 }
501 513
502 private setVideoRate (id: string, rateType: UserVideoRateType) { 514 private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
503 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` 515 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
504 const body: UserVideoRateUpdate = { 516 const body: UserVideoRateUpdate = {
505 rating: rateType 517 rating: rateType
506 } 518 }
519 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
507 520
508 return this.authHttp 521 return this.authHttp
509 .put(url, body) 522 .put(url, body, { headers })
510 .pipe(catchError(err => this.restExtractor.handleError(err))) 523 .pipe(catchError(err => this.restExtractor.handleError(err)))
511 } 524 }
512} 525}
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html
index 5650fa948..9f1455561 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.html
+++ b/client/src/app/shared/shared-share-modal/video-share.component.html
@@ -107,6 +107,10 @@
107 </a> 107 </a>
108 </div> 108 </div>
109 109
110 <div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning">
111 This video is password protected, please note that recipients will require the corresponding password to access the content.
112 </div>
113
110 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId"> 114 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
111 115
112 <ng-container ngbNavItem="url"> 116 <ng-container ngbNavItem="url">
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts
index 32f900f15..da4f2a4b4 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.ts
+++ b/client/src/app/shared/shared-share-modal/video-share.component.ts
@@ -243,6 +243,10 @@ export class VideoShareComponent {
243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE 243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
244 } 244 }
245 245
246 isPasswordProtectedVideo () {
247 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
248 }
249
246 private getPlaylistOptions (baseUrl?: string) { 250 private getPlaylistOptions (baseUrl?: string) {
247 return { 251 return {
248 baseUrl, 252 baseUrl,
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 8d2deedf7..3906652be 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -18,6 +18,7 @@ import {
18import { environment } from '../../../environments/environment' 18import { environment } from '../../../environments/environment'
19import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 19import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
20import { VideoComment } from './video-comment.model' 20import { VideoComment } from './video-comment.model'
21import { VideoPasswordService } from '../shared-main'
21 22
22@Injectable() 23@Injectable()
23export class VideoCommentService { 24export class VideoCommentService {
@@ -31,22 +32,25 @@ export class VideoCommentService {
31 private restService: RestService 32 private restService: RestService
32 ) {} 33 ) {}
33 34
34 addCommentThread (videoId: string, comment: VideoCommentCreate) { 35 addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) {
36 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
35 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 37 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
36 const normalizedComment = objectLineFeedToHtml(comment, 'text') 38 const normalizedComment = objectLineFeedToHtml(comment, 'text')
37 39
38 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 40 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
39 .pipe( 41 .pipe(
40 map(data => this.extractVideoComment(data.comment)), 42 map(data => this.extractVideoComment(data.comment)),
41 catchError(err => this.restExtractor.handleError(err)) 43 catchError(err => this.restExtractor.handleError(err))
42 ) 44 )
43 } 45 }
44 46
45 addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) { 47 addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) {
48 const { videoId, inReplyToCommentId, comment, videoPassword } = options
49 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
46 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId 50 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
47 const normalizedComment = objectLineFeedToHtml(comment, 'text') 51 const normalizedComment = objectLineFeedToHtml(comment, 'text')
48 52
49 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 53 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
50 .pipe( 54 .pipe(
51 map(data => this.extractVideoComment(data.comment)), 55 map(data => this.extractVideoComment(data.comment)),
52 catchError(err => this.restExtractor.handleError(err)) 56 catchError(err => this.restExtractor.handleError(err))
@@ -76,10 +80,13 @@ export class VideoCommentService {
76 80
77 getVideoCommentThreads (parameters: { 81 getVideoCommentThreads (parameters: {
78 videoId: string 82 videoId: string
83 videoPassword: string
79 componentPagination: ComponentPaginationLight 84 componentPagination: ComponentPaginationLight
80 sort: string 85 sort: string
81 }): Observable<ThreadsResultList<VideoComment>> { 86 }): Observable<ThreadsResultList<VideoComment>> {
82 const { videoId, componentPagination, sort } = parameters 87 const { videoId, videoPassword, componentPagination, sort } = parameters
88
89 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
83 90
84 const pagination = this.restService.componentToRestPagination(componentPagination) 91 const pagination = this.restService.componentToRestPagination(componentPagination)
85 92
@@ -87,7 +94,7 @@ export class VideoCommentService {
87 params = this.restService.addRestGetParams(params, pagination, sort) 94 params = this.restService.addRestGetParams(params, pagination, sort)
88 95
89 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 96 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
90 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params }) 97 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params, headers })
91 .pipe( 98 .pipe(
92 map(result => this.extractVideoComments(result)), 99 map(result => this.extractVideoComments(result)),
93 catchError(err => this.restExtractor.handleError(err)) 100 catchError(err => this.restExtractor.handleError(err))
@@ -97,12 +104,14 @@ export class VideoCommentService {
97 getVideoThreadComments (parameters: { 104 getVideoThreadComments (parameters: {
98 videoId: string 105 videoId: string
99 threadId: number 106 threadId: number
107 videoPassword?: string
100 }): Observable<VideoCommentThreadTree> { 108 }): Observable<VideoCommentThreadTree> {
101 const { videoId, threadId } = parameters 109 const { videoId, threadId, videoPassword } = parameters
102 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` 110 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
111 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
103 112
104 return this.authHttp 113 return this.authHttp
105 .get<VideoCommentThreadTreeServerModel>(url) 114 .get<VideoCommentThreadTreeServerModel>(url, { headers })
106 .pipe( 115 .pipe(
107 map(tree => this.extractVideoCommentTree(tree)), 116 map(tree => this.extractVideoCommentTree(tree)),
108 catchError(err => this.restExtractor.handleError(err)) 117 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts
index cac82d8d0..146ea7dfe 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -1,13 +1,13 @@
1import { mapValues } from 'lodash-es' 1import { mapValues } from 'lodash-es'
2import { firstValueFrom } from 'rxjs' 2import { firstValueFrom } from 'rxjs'
3import { tap } from 'rxjs/operators' 3import { tap } from 'rxjs/operators'
4import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
5import { HooksService } from '@app/core' 5import { HooksService } from '@app/core'
6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
7import { logger } from '@root-helpers/logger' 7import { logger } from '@root-helpers/logger'
8import { videoRequiresAuth } from '@root-helpers/video' 8import { videoRequiresFileToken } from '@root-helpers/video'
9import { objectKeysTyped, pick } from '@shared/core-utils' 9import { objectKeysTyped, pick } from '@shared/core-utils'
10import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' 10import { VideoCaption, VideoFile } from '@shared/models'
11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' 11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
12 12
13type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
21export class VideoDownloadComponent { 21export class VideoDownloadComponent {
22 @ViewChild('modal', { static: true }) modal: ElementRef 22 @ViewChild('modal', { static: true }) modal: ElementRef
23 23
24 @Input() videoPassword: string
25
24 downloadType: 'direct' | 'torrent' = 'direct' 26 downloadType: 'direct' | 'torrent' = 'direct'
25 27
26 resolutionId: number | string = -1 28 resolutionId: number | string = -1
@@ -89,8 +91,8 @@ export class VideoDownloadComponent {
89 this.subtitleLanguageId = this.videoCaptions[0].language.id 91 this.subtitleLanguageId = this.videoCaptions[0].language.id
90 } 92 }
91 93
92 if (videoRequiresAuth(this.video)) { 94 if (this.isConfidentialVideo()) {
93 this.videoFileTokenService.getVideoFileToken(this.video.uuid) 95 this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
94 .subscribe(({ token }) => this.videoFileToken = token) 96 .subscribe(({ token }) => this.videoFileToken = token)
95 } 97 }
96 98
@@ -201,7 +203,8 @@ export class VideoDownloadComponent {
201 } 203 }
202 204
203 isConfidentialVideo () { 205 isConfidentialVideo () {
204 return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 206 return videoRequiresFileToken(this.video)
207
205 } 208 }
206 209
207 switchToType (type: DownloadType) { 210 switchToType (type: DownloadType) {
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index 3d39c6fdc..3fbfaed28 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -125,7 +125,7 @@
125 <my-peertube-checkbox 125 <my-peertube-checkbox
126 formControlName="allVideos" 126 formControlName="allVideos"
127 inputName="allVideos" 127 inputName="allVideos"
128 i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" 128 i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
129 ></my-peertube-checkbox> 129 ></my-peertube-checkbox>
130 </div> 130 </div>
131 </div> 131 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 3f0180695..9e0a4f79b 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -5,6 +5,7 @@
5 > 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> 6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> 7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
8 </my-video-thumbnail> 9 </my-video-thumbnail>
9 10
10 <div class="video-bottom"> 11 <div class="video-bottom">
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 2384b34d7..d453f37a1 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
171 return this.video.privacy.id === VideoPrivacy.PRIVATE 171 return this.video.privacy.id === VideoPrivacy.PRIVATE
172 } 172 }
173 173
174 isPasswordProtectedVideo () {
175 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
176 }
177
174 getStateLabel (video: Video) { 178 getStateLabel (video: Video) {
175 if (!video.state) return '' 179 if (!video.state) return ''
176 180
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index 7b832263e..45df0be38 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -241,6 +241,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
241 } 241 }
242 242
243 reloadVideos () { 243 reloadVideos () {
244 console.log('reload')
244 this.pagination.currentPage = 1 245 this.pagination.currentPage = 1
245 this.loadMoreVideos(true) 246 this.loadMoreVideos(true)
246 } 247 }
@@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
420 421
421 if (reset) this.videos = [] 422 if (reset) this.videos = []
422 this.videos = this.videos.concat(data) 423 this.videos = this.videos.concat(data)
423 424 console.log('subscribe')
424 if (this.groupByDate) this.buildGroupedDateLabels() 425 if (this.groupByDate) this.buildGroupedDateLabels()
425 426
426 this.onDataSubject.next(data) 427 this.onDataSubject.next(data)
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
index 75afa0709..882b14c5e 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
@@ -21,7 +21,8 @@
21 [attr.title]="playlistElement.video.name" 21 [attr.title]="playlistElement.video.name"
22 >{{ playlistElement.video.name }}</a> 22 >{{ playlistElement.video.name }}</a>
23 23
24 <span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> 24 <span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
25 <span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
25 </div> 26 </div>
26 27
27 <span class="video-miniature-created-at-views"> 28 <span class="video-miniature-created-at-views">
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
index 552ea742b..b9a1d9623 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
@@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE 60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
61 } 61 }
62 62
63 isVideoPasswordProtected () {
64 return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
65 }
66
63 isUnavailable (e: VideoPlaylistElement) { 67 isUnavailable (e: VideoPlaylistElement) {
64 return e.type === VideoPlaylistElementType.UNAVAILABLE 68 return e.type === VideoPlaylistElementType.UNAVAILABLE
65 } 69 }
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/manager-options/hls-options-builder.ts
index 194991fa4..8091110bc 100644
--- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/hls-options-builder.ts
@@ -31,7 +31,7 @@ export class HLSOptionsBuilder {
31 const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader 31 const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
32 32
33 const p2pMediaLoader: P2PMediaLoaderPluginOptions = { 33 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
34 requiresAuth: commonOptions.requiresAuth, 34 requiresUserAuth: commonOptions.requiresUserAuth,
35 videoFileToken: commonOptions.videoFileToken, 35 videoFileToken: commonOptions.videoFileToken,
36 36
37 redundancyUrlManager, 37 redundancyUrlManager,
@@ -88,17 +88,24 @@ export class HLSOptionsBuilder {
88 httpFailedSegmentTimeout: 1000, 88 httpFailedSegmentTimeout: 1000,
89 89
90 xhrSetup: (xhr, url) => { 90 xhrSetup: (xhr, url) => {
91 if (!this.options.common.requiresAuth) return 91 const { requiresUserAuth, requiresPassword } = this.options.common
92
93 if (!(requiresUserAuth || requiresPassword)) return
94
92 if (!isSameOrigin(this.options.common.serverUrl, url)) return 95 if (!isSameOrigin(this.options.common.serverUrl, url)) return
93 96
94 xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) 97 if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword())
98
99 else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
95 }, 100 },
96 101
97 segmentValidator: segmentValidatorFactory({ 102 segmentValidator: segmentValidatorFactory({
98 segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, 103 segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
99 authorizationHeader: this.options.common.authorizationHeader, 104 authorizationHeader: this.options.common.authorizationHeader,
100 requiresAuth: this.options.common.requiresAuth, 105 requiresUserAuth: this.options.common.requiresUserAuth,
101 serverUrl: this.options.common.serverUrl 106 serverUrl: this.options.common.serverUrl,
107 requiresPassword: this.options.common.requiresPassword,
108 videoPassword: this.options.common.videoPassword
102 }), 109 }),
103 110
104 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), 111 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
index b5bdcd4e6..80eec02cf 100644
--- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
@@ -26,10 +26,10 @@ export class WebTorrentOptionsBuilder {
26 26
27 videoFileToken: commonOptions.videoFileToken, 27 videoFileToken: commonOptions.videoFileToken,
28 28
29 requiresAuth: commonOptions.requiresAuth, 29 requiresUserAuth: commonOptions.requiresUserAuth,
30 30
31 buildWebSeedUrls: file => { 31 buildWebSeedUrls: file => {
32 if (!commonOptions.requiresAuth) return [] 32 if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return []
33 33
34 return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] 34 return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
35 }, 35 },
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
index 44a31bfb4..e86d3d159 100644
--- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
@@ -13,11 +13,20 @@ function segmentValidatorFactory (options: {
13 serverUrl: string 13 serverUrl: string
14 segmentsSha256Url: string 14 segmentsSha256Url: string
15 authorizationHeader: () => string 15 authorizationHeader: () => string
16 requiresAuth: boolean 16 requiresUserAuth: boolean
17 requiresPassword: boolean
18 videoPassword: () => string
17}) { 19}) {
18 const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options 20 const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options
19 21
20 let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 22 let segmentsJSON = fetchSha256Segments({
23 serverUrl,
24 segmentsSha256Url,
25 authorizationHeader,
26 requiresUserAuth,
27 requiresPassword,
28 videoPassword
29 })
21 const regex = /bytes=(\d+)-(\d+)/ 30 const regex = /bytes=(\d+)-(\d+)/
22 31
23 return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { 32 return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
@@ -34,7 +43,14 @@ function segmentValidatorFactory (options: {
34 43
35 await wait(500) 44 await wait(500)
36 45
37 segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 46 segmentsJSON = fetchSha256Segments({
47 serverUrl,
48 segmentsSha256Url,
49 authorizationHeader,
50 requiresUserAuth,
51 requiresPassword,
52 videoPassword
53 })
38 await segmentValidator(segment, _method, _peerId, retry + 1) 54 await segmentValidator(segment, _method, _peerId, retry + 1)
39 55
40 return 56 return
@@ -78,13 +94,17 @@ function fetchSha256Segments (options: {
78 serverUrl: string 94 serverUrl: string
79 segmentsSha256Url: string 95 segmentsSha256Url: string
80 authorizationHeader: () => string 96 authorizationHeader: () => string
81 requiresAuth: boolean 97 requiresUserAuth: boolean
98 requiresPassword: boolean
99 videoPassword: () => string
82}): Promise<SegmentsJSON> { 100}): Promise<SegmentsJSON> {
83 const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options 101 const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options
84 102
85 const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url) 103 let headers: { [ id: string ]: string } = {}
86 ? { Authorization: authorizationHeader() } 104 if (isSameOrigin(serverUrl, segmentsSha256Url)) {
87 : {} 105 if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() }
106 else if (requiresUserAuth) headers = { Authorization: authorizationHeader() }
107 }
88 108
89 return fetch(segmentsSha256Url, { headers }) 109 return fetch(segmentsSha256Url, { headers })
90 .then(res => res.json() as Promise<SegmentsJSON>) 110 .then(res => res.json() as Promise<SegmentsJSON>)
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
index 3dde44a60..e2e220c03 100644
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
@@ -59,7 +59,7 @@ class WebTorrentPlugin extends Plugin {
59 private isAutoResolutionObservation = false 59 private isAutoResolutionObservation = false
60 private playerRefusedP2P = false 60 private playerRefusedP2P = false
61 61
62 private requiresAuth: boolean 62 private requiresUserAuth: boolean
63 private videoFileToken: () => string 63 private videoFileToken: () => string
64 64
65 private torrentInfoInterval: any 65 private torrentInfoInterval: any
@@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin {
86 this.savePlayerSrcFunction = this.player.src 86 this.savePlayerSrcFunction = this.player.src
87 this.playerElement = options.playerElement 87 this.playerElement = options.playerElement
88 88
89 this.requiresAuth = options.requiresAuth 89 this.requiresUserAuth = options.requiresUserAuth
90 this.videoFileToken = options.videoFileToken 90 this.videoFileToken = options.videoFileToken
91 91
92 this.buildWebSeedUrls = options.buildWebSeedUrls 92 this.buildWebSeedUrls = options.buildWebSeedUrls
@@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin {
546 546
547 let httpUrl = this.currentVideoFile.fileUrl 547 let httpUrl = this.currentVideoFile.fileUrl
548 548
549 if (this.requiresAuth && this.videoFileToken) { 549 if (this.videoFileToken) {
550 httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) 550 httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
551 } 551 }
552 552
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index c14fd7e99..1f3a0aa2e 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -83,8 +83,10 @@ export interface CommonOptions extends CustomizationOptions {
83 videoShortUUID: string 83 videoShortUUID: string
84 84
85 serverUrl: string 85 serverUrl: string
86 requiresAuth: boolean 86 requiresUserAuth: boolean
87 videoFileToken: () => string 87 videoFileToken: () => string
88 requiresPassword: boolean
89 videoPassword: () => string
88 90
89 errorNotifier: (message: string) => void 91 errorNotifier: (message: string) => void
90} 92}
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index eadf56cfa..723c42c5d 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -155,7 +155,7 @@ type WebtorrentPluginOptions = {
155 155
156 playerRefusedP2P: boolean 156 playerRefusedP2P: boolean
157 157
158 requiresAuth: boolean 158 requiresUserAuth: boolean
159 videoFileToken: () => string 159 videoFileToken: () => string
160 160
161 buildWebSeedUrls: (file: VideoFile) => string[] 161 buildWebSeedUrls: (file: VideoFile) => string[]
@@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = {
170 170
171 loader: P2PMediaLoader 171 loader: P2PMediaLoader
172 172
173 requiresAuth: boolean 173 requiresUserAuth: boolean
174 videoFileToken: () => string 174 videoFileToken: () => string
175} 175}
176 176
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts
index 9022b908b..4a44615fb 100644
--- a/client/src/root-helpers/video.ts
+++ b/client/src/root-helpers/video.ts
@@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b
41 return userP2PEnabled 41 return userP2PEnabled
42} 42}
43 43
44function videoRequiresAuth (video: Video) { 44function videoRequiresUserAuth (video: Video, videoPassword?: string) {
45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) 45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
46 (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
47
48}
49
50function videoRequiresFileToken (video: Video, videoPassword?: string) {
51 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
46} 52}
47 53
48export { 54export {
49 buildVideoOrPlaylistEmbed, 55 buildVideoOrPlaylistEmbed,
50 isP2PEnabled, 56 isP2PEnabled,
51 videoRequiresAuth 57 videoRequiresUserAuth,
58 videoRequiresFileToken
52} 59}
53 60
54// --------------------------------------------------------------------------- 61// ---------------------------------------------------------------------------
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html
index 32bf5f655..a74bb4cee 100644
--- a/client/src/standalone/videos/embed.html
+++ b/client/src/standalone/videos/embed.html
@@ -41,6 +41,23 @@
41 <div id="error-content"></div> 41 <div id="error-content"></div>
42 </div> 42 </div>
43 43
44 <div id="video-password-block">
45 <!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
46 <h1 id="video-password-title"></h1>
47
48 <div id="video-password-content"></div>
49
50 <form id="video-password-form">
51 <input type="password" id="video-password-input" name="video-password" required>
52 <button type="submit" id="video-password-submit"> </button>
53 </form>
54
55 <div id="video-password-error"></div>
56 <svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24">
57 <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>
58 </svg>
59 </div>
60
44 <div id="video-wrapper"></div> 61 <div id="video-wrapper"></div>
45 62
46 <div id="placeholder-preview"></div> 63 <div id="placeholder-preview"></div>
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 3631ea7e6..d15887478 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -24,7 +24,7 @@ html,
24body { 24body {
25 height: 100%; 25 height: 100%;
26 margin: 0; 26 margin: 0;
27 background-color: #000; 27 background-color: #0f0f10;
28} 28}
29 29
30#video-wrapper { 30#video-wrapper {
@@ -42,8 +42,10 @@ body {
42 } 42 }
43} 43}
44 44
45#error-block { 45#error-block,
46#video-password-block {
46 display: none; 47 display: none;
48 user-select: none;
47 49
48 flex-direction: column; 50 flex-direction: column;
49 align-content: center; 51 align-content: center;
@@ -86,6 +88,43 @@ body {
86 text-align: center; 88 text-align: center;
87} 89}
88 90
91#video-password-content {
92 @include margin(1rem, 0, 2rem);
93}
94
95#video-password-input,
96#video-password-submit {
97 line-height: 23px;
98 padding: 1rem;
99 margin: 1rem 0.5rem;
100 border: 0;
101 font-weight: 600;
102 border-radius: 3px!important;
103 font-size: 18px;
104 display: inline-block;
105}
106
107#video-password-submit {
108 color: #fff;
109 background-color: #f2690d;
110 cursor: pointer;
111}
112
113#video-password-submit:hover {
114 background-color: #f47825;
115}
116#video-password-error {
117 margin-top: 10px;
118 margin-bottom: 10px;
119 height: 2rem;
120 font-weight: bolder;
121}
122
123#video-password-block svg {
124 margin-left: auto;
125 margin-right: auto;
126}
127
89@media screen and (max-width: 300px) { 128@media screen and (max-width: 300px) {
90 #error-block { 129 #error-block {
91 font-size: 36px; 130 font-size: 36px;
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index cc4274b99..cffda2cc7 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,10 +3,18 @@ import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import videojs from 'video.js' 4import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 5import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
6import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' 6import {
7 HTMLServerConfig,
8 ResultList,
9 ServerErrorCode,
10 VideoDetails,
11 VideoPlaylist,
12 VideoPlaylistElement,
13 VideoState
14} from '../../../../shared/models'
7import { PeertubePlayerManager } from '../../assets/player' 15import { PeertubePlayerManager } from '../../assets/player'
8import { TranslationsManager } from '../../assets/player/translations-manager' 16import { TranslationsManager } from '../../assets/player/translations-manager'
9import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' 17import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
10import { PeerTubeEmbedApi } from './embed-api' 18import { PeerTubeEmbedApi } from './embed-api'
11import { 19import {
12 AuthHTTP, 20 AuthHTTP,
@@ -19,6 +27,7 @@ import {
19 VideoFetcher 27 VideoFetcher
20} from './shared' 28} from './shared'
21import { PlayerHTML } from './shared/player-html' 29import { PlayerHTML } from './shared/player-html'
30import { PeerTubeServerError } from 'src/types'
22 31
23export class PeerTubeEmbed { 32export class PeerTubeEmbed {
24 player: videojs.Player 33 player: videojs.Player
@@ -38,6 +47,8 @@ export class PeerTubeEmbed {
38 private readonly liveManager: LiveManager 47 private readonly liveManager: LiveManager
39 48
40 private playlistTracker: PlaylistTracker 49 private playlistTracker: PlaylistTracker
50 private videoPassword: string
51 private requiresPassword: boolean
41 52
42 constructor (videoWrapperId: string) { 53 constructor (videoWrapperId: string) {
43 logger.registerServerSending(window.location.origin) 54 logger.registerServerSending(window.location.origin)
@@ -50,6 +61,7 @@ export class PeerTubeEmbed {
50 this.playerHTML = new PlayerHTML(videoWrapperId) 61 this.playerHTML = new PlayerHTML(videoWrapperId)
51 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) 62 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
52 this.liveManager = new LiveManager(this.playerHTML) 63 this.liveManager = new LiveManager(this.playerHTML)
64 this.requiresPassword = false
53 65
54 try { 66 try {
55 this.config = JSON.parse((window as any)['PeerTubeServerConfig']) 67 this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
@@ -176,11 +188,13 @@ export class PeerTubeEmbed {
176 const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options 188 const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
177 189
178 try { 190 try {
179 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) 191 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
180 192
181 return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) 193 return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay })
182 } catch (err) { 194 } catch (err) {
183 this.playerHTML.displayError(err.message, await this.translationsPromise) 195
196 if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
197 else this.playerHTML.displayError(err.message, await this.translationsPromise)
184 } 198 }
185 } 199 }
186 200
@@ -205,8 +219,8 @@ export class PeerTubeEmbed {
205 ? await this.videoFetcher.loadLive(videoInfo) 219 ? await this.videoFetcher.loadLive(videoInfo)
206 : undefined 220 : undefined
207 221
208 const videoFileToken = videoRequiresAuth(videoInfo) 222 const videoFileToken = videoRequiresFileToken(videoInfo)
209 ? await this.videoFetcher.loadVideoToken(videoInfo) 223 ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
210 : undefined 224 : undefined
211 225
212 return { live, video: videoInfo, videoFileToken } 226 return { live, video: videoInfo, videoFileToken }
@@ -232,6 +246,8 @@ export class PeerTubeEmbed {
232 246
233 authorizationHeader: () => this.http.getHeaderTokenValue(), 247 authorizationHeader: () => this.http.getHeaderTokenValue(),
234 videoFileToken: () => videoFileToken, 248 videoFileToken: () => videoFileToken,
249 videoPassword: () => this.videoPassword,
250 requiresPassword: this.requiresPassword,
235 251
236 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), 252 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
237 253
@@ -263,6 +279,7 @@ export class PeerTubeEmbed {
263 this.initializeApi() 279 this.initializeApi()
264 280
265 this.playerHTML.removePlaceholder() 281 this.playerHTML.removePlaceholder()
282 if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
266 283
267 if (this.isPlaylistEmbed()) { 284 if (this.isPlaylistEmbed()) {
268 await this.buildPlayerPlaylistUpnext() 285 await this.buildPlayerPlaylistUpnext()
@@ -401,6 +418,21 @@ export class PeerTubeEmbed {
401 (this.player.el() as HTMLElement).style.pointerEvents = 'none' 418 (this.player.el() as HTMLElement).style.pointerEvents = 'none'
402 } 419 }
403 420
421 private async handlePasswordError (err: PeerTubeServerError) {
422 let incorrectPassword: boolean = null
423 if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
424 else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true
425
426 if (incorrectPassword === null) return false
427
428 this.requiresPassword = true
429 this.videoPassword = await this.playerHTML.askVideoPassword({
430 incorrectPassword,
431 translations: await this.translationsPromise
432 })
433 return true
434 }
435
404} 436}
405 437
406PeerTubeEmbed.main() 438PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
index 95e3b029e..c1e9f7750 100644
--- a/client/src/standalone/videos/shared/auth-http.ts
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -18,10 +18,12 @@ export class AuthHTTP {
18 if (this.userOAuthTokens) this.setHeadersFromTokens() 18 if (this.userOAuthTokens) this.setHeadersFromTokens()
19 } 19 }
20 20
21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { 21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
22 const refreshFetchOptions = optionalAuth 22 let refreshFetchOptions: { headers?: Headers } = {}
23 ? { headers: this.headers } 23
24 : {} 24 if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
25
26 if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }
25 27
26 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) 28 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
27 } 29 }
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
index d93678c10..a0846d9d7 100644
--- a/client/src/standalone/videos/shared/player-html.ts
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -55,6 +55,58 @@ export class PlayerHTML {
55 this.wrapperElement.style.display = 'none' 55 this.wrapperElement.style.display = 'none'
56 } 56 }
57 57
58 async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> {
59 const { incorrectPassword, translations } = options
60 return new Promise((resolve) => {
61
62 this.removePlaceholder()
63 this.wrapperElement.style.display = 'none'
64
65 const translatedTitle = peertubeTranslate('This video is password protected', translations)
66 const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations)
67
68 document.title = translatedTitle
69
70 const videoPasswordBlock = document.getElementById('video-password-block')
71 videoPasswordBlock.style.display = 'flex'
72
73 const videoPasswordTitle = document.getElementById('video-password-title')
74 videoPasswordTitle.innerHTML = translatedTitle
75
76 const videoPasswordMessage = document.getElementById('video-password-content')
77 videoPasswordMessage.innerHTML = translatedMessage
78
79 if (incorrectPassword) {
80 const videoPasswordError = document.getElementById('video-password-error')
81 videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations)
82 videoPasswordError.style.transform = 'scale(1.2)'
83
84 setTimeout(() => {
85 videoPasswordError.style.transform = 'scale(1)'
86 }, 500)
87 }
88
89 const videoPasswordSubmitButton = document.getElementById('video-password-submit')
90 videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations)
91
92 const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement
93 videoPasswordInput.placeholder = peertubeTranslate('Password', translations)
94
95 const videoPasswordForm = document.getElementById('video-password-form')
96 videoPasswordForm.addEventListener('submit', (event) => {
97 event.preventDefault()
98 const videoPassword = videoPasswordInput.value
99 resolve(videoPassword)
100 })
101 })
102 }
103
104 removeVideoPasswordBlock () {
105 const videoPasswordBlock = document.getElementById('video-password-block')
106 videoPasswordBlock.style.display = 'none'
107 this.wrapperElement.style.display = 'block'
108 }
109
58 buildPlaceholder (video: VideoDetails) { 110 buildPlaceholder (video: VideoDetails) {
59 const placeholder = this.getPlaceholderElement() 111 const placeholder = this.getPlaceholderElement()
60 112
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
index 43ae22a3b..587516410 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -18,7 +18,7 @@ import {
18 logger, 18 logger,
19 peertubeLocalStorage, 19 peertubeLocalStorage,
20 UserLocalStorageKeys, 20 UserLocalStorageKeys,
21 videoRequiresAuth 21 videoRequiresUserAuth
22} from '../../../root-helpers' 22} from '../../../root-helpers'
23import { PeerTubePlugin } from './peertube-plugin' 23import { PeerTubePlugin } from './peertube-plugin'
24import { PlayerHTML } from './player-html' 24import { PlayerHTML } from './player-html'
@@ -162,6 +162,9 @@ export class PlayerManagerOptions {
162 authorizationHeader: () => string 162 authorizationHeader: () => string
163 videoFileToken: () => string 163 videoFileToken: () => string
164 164
165 videoPassword: () => string
166 requiresPassword: boolean
167
165 serverConfig: HTMLServerConfig 168 serverConfig: HTMLServerConfig
166 169
167 autoplayFromPreviousVideo: boolean 170 autoplayFromPreviousVideo: boolean
@@ -178,6 +181,8 @@ export class PlayerManagerOptions {
178 captionsResponse, 181 captionsResponse,
179 autoplayFromPreviousVideo, 182 autoplayFromPreviousVideo,
180 videoFileToken, 183 videoFileToken,
184 videoPassword,
185 requiresPassword,
181 translations, 186 translations,
182 forceAutoplay, 187 forceAutoplay,
183 playlistTracker, 188 playlistTracker,
@@ -242,10 +247,13 @@ export class PlayerManagerOptions {
242 embedUrl: window.location.origin + video.embedPath, 247 embedUrl: window.location.origin + video.embedPath,
243 embedTitle: video.name, 248 embedTitle: video.name,
244 249
245 requiresAuth: videoRequiresAuth(video), 250 requiresUserAuth: videoRequiresUserAuth(video),
246 authorizationHeader, 251 authorizationHeader,
247 videoFileToken, 252 videoFileToken,
248 253
254 requiresPassword,
255 videoPassword,
256
249 errorNotifier: () => { 257 errorNotifier: () => {
250 // Empty, we don't have a notifier in the embed 258 // Empty, we don't have a notifier in the embed
251 }, 259 },
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
index cf6d12831..76ba0a3ed 100644
--- a/client/src/standalone/videos/shared/video-fetcher.ts
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -1,3 +1,4 @@
1import { PeerTubeServerError } from '../../../types'
1import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' 2import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
2import { logger } from '../../../root-helpers' 3import { logger } from '../../../root-helpers'
3import { AuthHTTP } from './auth-http' 4import { AuthHTTP } from './auth-http'
@@ -8,8 +9,8 @@ export class VideoFetcher {
8 9
9 } 10 }
10 11
11 async loadVideo (videoId: string) { 12 async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
12 const videoPromise = this.loadVideoInfo(videoId) 13 const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
13 14
14 let videoResponse: Response 15 let videoResponse: Response
15 let isResponseOk: boolean 16 let isResponseOk: boolean
@@ -27,11 +28,14 @@ export class VideoFetcher {
27 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { 28 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
28 throw new Error('This video does not exist.') 29 throw new Error('This video does not exist.')
29 } 30 }
30 31 if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) {
32 const res = await videoResponse.json()
33 throw new PeerTubeServerError(res.message, res.code)
34 }
31 throw new Error('We cannot fetch the video. Please try again later.') 35 throw new Error('We cannot fetch the video. Please try again later.')
32 } 36 }
33 37
34 const captionsPromise = this.loadVideoCaptions(videoId) 38 const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
35 39
36 return { captionsPromise, videoResponse } 40 return { captionsPromise, videoResponse }
37 } 41 }
@@ -41,8 +45,8 @@ export class VideoFetcher {
41 .then(res => res.json() as Promise<LiveVideo>) 45 .then(res => res.json() as Promise<LiveVideo>)
42 } 46 }
43 47
44 loadVideoToken (video: VideoDetails) { 48 loadVideoToken (video: VideoDetails, videoPassword?: string) {
45 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) 49 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
46 .then(res => res.json() as Promise<VideoToken>) 50 .then(res => res.json() as Promise<VideoToken>)
47 .then(token => token.files.token) 51 .then(token => token.files.token)
48 } 52 }
@@ -51,12 +55,12 @@ export class VideoFetcher {
51 return this.getVideoUrl(videoUUID) + '/views' 55 return this.getVideoUrl(videoUUID) + '/views'
52 } 56 }
53 57
54 private loadVideoInfo (videoId: string): Promise<Response> { 58 private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
55 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) 59 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
56 } 60 }
57 61
58 private loadVideoCaptions (videoId: string): Promise<Response> { 62 private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
59 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) 63 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
60 } 64 }
61 65
62 private getVideoUrl (id: string) { 66 private getVideoUrl (id: string) {
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index 5508515fd..60564496c 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -1,4 +1,5 @@
1export * from './client-script.model' 1export * from './client-script.model'
2export * from './server-error.model'
2export * from './job-state-client.type' 3export * from './job-state-client.type'
3export * from './job-type-client.type' 4export * from './job-type-client.type'
4export * from './link.type' 5export * from './link.type'
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts
new file mode 100644
index 000000000..4a57287fe
--- /dev/null
+++ b/client/src/types/server-error.model.ts
@@ -0,0 +1,11 @@
1import { ServerErrorCode } from '@shared/models/index'
2
3export class PeerTubeServerError extends Error {
4 serverCode: ServerErrorCode
5
6 constructor (message: string, serverCode: ServerErrorCode) {
7 super(message)
8 this.name = 'CustomError'
9 this.serverCode = serverCode
10 }
11}