aboutsummaryrefslogtreecommitdiffhomepage
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
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
-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
-rwxr-xr-xscripts/i18n/create-custom-files.ts5
-rw-r--r--server/controllers/api/videos/import.ts1
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/api/videos/live.ts7
-rw-r--r--server/controllers/api/videos/passwords.ts105
-rw-r--r--server/controllers/api/videos/token.ts16
-rw-r--r--server/controllers/api/videos/update.ts16
-rw-r--r--server/controllers/api/videos/upload.ts7
-rw-r--r--server/helpers/custom-validators/videos.ts57
-rw-r--r--server/initializers/constants.ts10
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/initializers/migrations/0785-video-password-protection.ts31
-rw-r--r--server/lib/client-html.ts3
-rw-r--r--server/lib/video-pre-import.ts11
-rw-r--r--server/lib/video-privacy.ts10
-rw-r--r--server/lib/video-tokens-manager.ts25
-rw-r--r--server/middlewares/auth.ts15
-rw-r--r--server/middlewares/validators/shared/index.ts1
-rw-r--r--server/middlewares/validators/shared/video-passwords.ts80
-rw-r--r--server/middlewares/validators/shared/videos.ts85
-rw-r--r--server/middlewares/validators/sort.ts1
-rw-r--r--server/middlewares/validators/static.ts10
-rw-r--r--server/middlewares/validators/videos/index.ts2
-rw-r--r--server/middlewares/validators/videos/video-captions.ts5
-rw-r--r--server/middlewares/validators/videos/video-comments.ts7
-rw-r--r--server/middlewares/validators/videos/video-imports.ts12
-rw-r--r--server/middlewares/validators/videos/video-live.ts13
-rw-r--r--server/middlewares/validators/videos/video-passwords.ts77
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts2
-rw-r--r--server/middlewares/validators/videos/video-rates.ts3
-rw-r--r--server/middlewares/validators/videos/video-token.ts24
-rw-r--r--server/middlewares/validators/videos/videos.ts24
-rw-r--r--server/models/video/video-password.ts137
-rw-r--r--server/models/video/video-playlist-element.ts5
-rw-r--r--server/models/video/video.ts18
-rw-r--r--server/tests/api/check-params/live.ts4
-rw-r--r--server/tests/api/check-params/video-passwords.ts609
-rw-r--r--server/tests/api/check-params/video-token.ts44
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts132
-rw-r--r--server/tests/api/videos/video-passwords.ts97
-rw-r--r--server/tests/api/videos/video-playlists.ts17
-rw-r--r--server/tests/api/videos/video-static-file-privacy.ts127
-rw-r--r--server/tests/client.ts13
-rw-r--r--server/tests/feeds/feeds.ts9
-rw-r--r--server/types/express.d.ts3
-rw-r--r--server/types/models/video/index.ts1
-rw-r--r--server/types/models/video/video-password.ts3
-rw-r--r--server/types/models/video/video.ts4
-rw-r--r--shared/core-utils/videos/common.ts2
-rw-r--r--shared/models/server/server-error-code.enum.ts5
-rw-r--r--shared/models/videos/index.ts1
-rw-r--r--shared/models/videos/video-create.model.ts1
-rw-r--r--shared/models/videos/video-password.model.ts7
-rw-r--r--shared/models/videos/video-privacy.enum.ts3
-rw-r--r--shared/models/videos/video-update.model.ts1
-rw-r--r--shared/server-commands/requests/requests.ts4
-rw-r--r--shared/server-commands/server/server.ts3
-rw-r--r--shared/server-commands/shared/abstract-command.ts18
-rw-r--r--shared/server-commands/videos/captions-command.ts4
-rw-r--r--shared/server-commands/videos/comments-command.ts12
-rw-r--r--shared/server-commands/videos/index.ts1
-rw-r--r--shared/server-commands/videos/live-command.ts12
-rw-r--r--shared/server-commands/videos/video-passwords-command.ts55
-rw-r--r--shared/server-commands/videos/video-token-command.ts5
-rw-r--r--shared/server-commands/videos/videos-command.ts23
-rw-r--r--support/doc/api/openapi.yaml120
122 files changed, 2632 insertions, 252 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}
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index 72136614c..d03d0fe83 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -69,7 +69,10 @@ const playerKeys = {
69 '{1} from servers · {2} from peers': '{1} from servers · {2} from peers', 69 '{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
70 'Previous video': 'Previous video', 70 'Previous video': 'Previous video',
71 'Video page (new window)': 'Video page (new window)', 71 'Video page (new window)': 'Video page (new window)',
72 'Next video': 'Next video' 72 'Next video': 'Next video',
73 'This video is password protected': 'This video is password protected',
74 'You need a password to watch this video.': 'You need a password to watch this video.',
75 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password'
73} 76}
74Object.assign(playerKeys, videojs) 77Object.assign(playerKeys, videojs)
75 78
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 6a50aaf4e..b8016140e 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
120 videoChannel: res.locals.videoChannel, 120 videoChannel: res.locals.videoChannel,
121 tags: body.tags || undefined, 121 tags: body.tags || undefined,
122 user, 122 user,
123 videoPasswords: body.videoPasswords,
123 videoImportAttributes: { 124 videoImportAttributes: {
124 magnetUri, 125 magnetUri,
125 torrentName, 126 torrentName,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index a34325e79..d0eecf812 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding'
47import { updateRouter } from './update' 47import { updateRouter } from './update'
48import { uploadRouter } from './upload' 48import { uploadRouter } from './upload'
49import { viewRouter } from './view' 49import { viewRouter } from './view'
50import { videoPasswordRouter } from './passwords'
50 51
51const auditLogger = auditLoggerFactory('videos') 52const auditLogger = auditLoggerFactory('videos')
52const videosRouter = express.Router() 53const videosRouter = express.Router()
@@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter)
68videosRouter.use('/', filesRouter) 69videosRouter.use('/', filesRouter)
69videosRouter.use('/', transcodingRouter) 70videosRouter.use('/', transcodingRouter)
70videosRouter.use('/', tokenRouter) 71videosRouter.use('/', tokenRouter)
72videosRouter.use('/', videoPasswordRouter)
71 73
72videosRouter.get('/categories', 74videosRouter.get('/categories',
73 openapiOperationDoc({ operationId: 'getCategories' }), 75 openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index de047d4ec..cf82c9791 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 18import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' 19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
20import { buildUUID, uuidToShort } from '@shared/extra-utils' 20import { buildUUID, uuidToShort } from '@shared/extra-utils'
21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' 21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { sequelizeTypescript } from '../../../initializers/database' 23import { sequelizeTypescript } from '../../../initializers/database'
24import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' 24import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' 25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video' 26import { VideoModel } from '../../../models/video/video'
27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' 27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
28import { VideoPasswordModel } from '@server/models/video/video-password'
28 29
29const liveRouter = express.Router() 30const liveRouter = express.Router()
30 31
@@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
202 203
203 await federateVideoIfNeeded(videoCreated, true, t) 204 await federateVideoIfNeeded(videoCreated, true, t)
204 205
206 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
207 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
208 }
209
205 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) 210 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
206 211
207 return { videoCreated } 212 return { videoCreated }
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts
new file mode 100644
index 000000000..d11cf5bcc
--- /dev/null
+++ b/server/controllers/api/videos/passwords.ts
@@ -0,0 +1,105 @@
1import express from 'express'
2
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { getFormattedObjects } from '../../../helpers/utils'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../../middlewares'
12import {
13 listVideoPasswordValidator,
14 paginationValidator,
15 removeVideoPasswordValidator,
16 updateVideoPasswordListValidator,
17 videoPasswordsSortValidator
18} from '../../../middlewares/validators'
19import { VideoPasswordModel } from '@server/models/video/video-password'
20import { logger, loggerTagsFactory } from '@server/helpers/logger'
21import { Transaction } from 'sequelize'
22import { getVideoWithAttributes } from '@server/helpers/video'
23
24const lTags = loggerTagsFactory('api', 'video')
25const videoPasswordRouter = express.Router()
26
27videoPasswordRouter.get('/:videoId/passwords',
28 authenticate,
29 paginationValidator,
30 videoPasswordsSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 asyncMiddleware(listVideoPasswordValidator),
34 asyncMiddleware(listVideoPasswords)
35)
36
37videoPasswordRouter.put('/:videoId/passwords',
38 authenticate,
39 asyncMiddleware(updateVideoPasswordListValidator),
40 asyncMiddleware(updateVideoPasswordList)
41)
42
43videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
44 authenticate,
45 asyncMiddleware(removeVideoPasswordValidator),
46 asyncRetryTransactionMiddleware(removeVideoPassword)
47)
48
49// ---------------------------------------------------------------------------
50
51export {
52 videoPasswordRouter
53}
54
55// ---------------------------------------------------------------------------
56
57async function listVideoPasswords (req: express.Request, res: express.Response) {
58 const options = {
59 videoId: res.locals.videoAll.id,
60 start: req.query.start,
61 count: req.query.count,
62 sort: req.query.sort
63 }
64
65 const resultList = await VideoPasswordModel.listPasswords(options)
66
67 return res.json(getFormattedObjects(resultList.data, resultList.total))
68}
69
70async function updateVideoPasswordList (req: express.Request, res: express.Response) {
71 const videoInstance = getVideoWithAttributes(res)
72 const videoId = videoInstance.id
73
74 const passwordArray = req.body.passwords as string[]
75
76 await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
77 await VideoPasswordModel.deleteAllPasswords(videoId, t)
78 await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
79 })
80
81 logger.info(
82 `Video passwords for video with name %s and uuid %s have been updated`,
83 videoInstance.name,
84 videoInstance.uuid,
85 lTags(videoInstance.uuid)
86 )
87
88 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
89}
90
91async function removeVideoPassword (req: express.Request, res: express.Response) {
92 const videoInstance = getVideoWithAttributes(res)
93 const password = res.locals.videoPassword
94
95 await VideoPasswordModel.deletePassword(password.id)
96 logger.info(
97 'Password with id %d of video named %s and uuid %s has been deleted.',
98 password.id,
99 videoInstance.name,
100 videoInstance.uuid,
101 lTags(videoInstance.uuid)
102 )
103
104 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
105}
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 22387c3e8..e961ffd9e 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -1,13 +1,14 @@
1import express from 'express' 1import express from 'express'
2import { VideoTokensManager } from '@server/lib/video-tokens-manager' 2import { VideoTokensManager } from '@server/lib/video-tokens-manager'
3import { VideoToken } from '@shared/models' 3import { VideoPrivacy, VideoToken } from '@shared/models'
4import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' 4import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
5 5
6const tokenRouter = express.Router() 6const tokenRouter = express.Router()
7 7
8tokenRouter.post('/:id/token', 8tokenRouter.post('/:id/token',
9 authenticate, 9 optionalAuthenticate,
10 asyncMiddleware(videosCustomGetValidator('only-video')), 10 asyncMiddleware(videosCustomGetValidator('only-video')),
11 videoFileTokenValidator,
11 generateToken 12 generateToken
12) 13)
13 14
@@ -22,12 +23,11 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 23function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 24 const video = res.locals.onlyVideo
24 25
25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) 26 const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
27 ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
28 : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 29
27 return res.json({ 30 return res.json({
28 files: { 31 files
29 token,
30 expires
31 }
32 } as VideoToken) 32 } as VideoToken)
33} 33}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index ddab428d4..28ec2cf37 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,13 +2,12 @@ import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { VideoPathManager } from '@server/lib/video-path-manager'
6import { setVideoPrivacy } from '@server/lib/video-privacy' 5import { setVideoPrivacy } from '@server/lib/video-privacy'
7import { openapiOperationDoc } from '@server/middlewares/doc' 6import { openapiOperationDoc } from '@server/middlewares/doc'
8import { FilteredModelAttributes } from '@server/types' 7import { FilteredModelAttributes } from '@server/types'
9import { MVideoFullLight } from '@server/types/models' 8import { MVideoFullLight } from '@server/types/models'
10import { forceNumber } from '@shared/core-utils' 9import { forceNumber } from '@shared/core-utils'
11import { HttpStatusCode, VideoUpdate } from '@shared/models' 10import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
12import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 11import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
13import { resetSequelizeInstance } from '../../../helpers/database-utils' 12import { resetSequelizeInstance } from '../../../helpers/database-utils'
14import { createReqFiles } from '../../../helpers/express-utils' 13import { createReqFiles } from '../../../helpers/express-utils'
@@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' 19import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 20import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
22import { VideoModel } from '../../../models/video/video' 21import { VideoModel } from '../../../models/video/video'
22import { VideoPathManager } from '@server/lib/video-path-manager'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24const lTags = loggerTagsFactory('api', 'video') 26const lTags = loggerTagsFactory('api', 'video')
25const auditLogger = auditLoggerFactory('videos') 27const auditLogger = auditLoggerFactory('videos')
@@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
176 const newPrivacy = forceNumber(videoInfoToUpdate.privacy) 178 const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
177 setVideoPrivacy(videoInstance, newPrivacy) 179 setVideoPrivacy(videoInstance, newPrivacy)
178 180
181 // Delete passwords if video is not anymore password protected
182 if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
183 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
184 }
185
186 if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
187 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
188 await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
189 }
190
179 // Unfederate the video if the new privacy is not compatible with federation 191 // Unfederate the video if the new privacy is not compatible with federation
180 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { 192 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
181 await VideoModel.sendDelete(videoInstance, { transaction }) 193 await VideoModel.sendDelete(videoInstance, { transaction })
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 885ac8b81..073eb480f 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoSourceModel } from '@server/models/video/video-source' 14import { VideoSourceModel } from '@server/models/video/video-source'
15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' 15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils' 16import { uuidToShort } from '@shared/extra-utils'
17import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models' 17import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
19import { createReqFiles } from '../../../helpers/express-utils' 19import { createReqFiles } from '../../../helpers/express-utils'
20import { logger, loggerTagsFactory } from '../../../helpers/logger' 20import { logger, loggerTagsFactory } from '../../../helpers/logger'
@@ -33,6 +33,7 @@ import {
33} from '../../../middlewares' 33} from '../../../middlewares'
34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
35import { VideoModel } from '../../../models/video/video' 35import { VideoModel } from '../../../models/video/video'
36import { VideoPasswordModel } from '@server/models/video/video-password'
36 37
37const lTags = loggerTagsFactory('api', 'video') 38const lTags = loggerTagsFactory('api', 'video')
38const auditLogger = auditLoggerFactory('videos') 39const auditLogger = auditLoggerFactory('videos')
@@ -195,6 +196,10 @@ async function addVideo (options: {
195 transaction: t 196 transaction: t
196 }) 197 })
197 198
199 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
200 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
201 }
202
198 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) 203 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
199 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) 204 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
200 205
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 5f75ec27c..91109217c 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,7 +1,7 @@
1import { UploadFilesForCheck } from 'express' 1import { Response, Request, UploadFilesForCheck } from 'express'
2import { decode as magnetUriDecode } from 'magnet-uri' 2import { decode as magnetUriDecode } from 'magnet-uri'
3import validator from 'validator' 3import validator from 'validator'
4import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' 4import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
5import { 5import {
6 CONSTRAINTS_FIELDS, 6 CONSTRAINTS_FIELDS,
7 MIMETYPES, 7 MIMETYPES,
@@ -13,6 +13,7 @@ import {
13 VIDEO_STATES 13 VIDEO_STATES
14} from '../../initializers/constants' 14} from '../../initializers/constants'
15import { exists, isArray, isDateValid, isFileValid } from './misc' 15import { exists, isArray, isDateValid, isFileValid } from './misc'
16import { getVideoWithAttributes } from '@server/helpers/video'
16 17
17const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
18 19
@@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
110 return VIDEO_PRIVACIES[value] !== undefined 111 return VIDEO_PRIVACIES[value] !== undefined
111} 112}
112 113
114function isVideoReplayPrivacyValid (value: number) {
115 return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
116}
117
113function isScheduleVideoUpdatePrivacyValid (value: number) { 118function isScheduleVideoUpdatePrivacyValid (value: number) {
114 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL 119 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
115} 120}
@@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
141 return parsed && isVideoFileInfoHashValid(parsed.infoHash) 146 return parsed && isVideoFileInfoHashValid(parsed.infoHash)
142} 147}
143 148
149function isPasswordValid (password: string) {
150 return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min &&
151 password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max
152}
153
154function isValidPasswordProtectedPrivacy (req: Request, res: Response) {
155 const fail = (message: string) => {
156 res.fail({
157 status: HttpStatusCode.BAD_REQUEST_400,
158 message
159 })
160 return false
161 }
162
163 let privacy: VideoPrivacy
164 const video = getVideoWithAttributes(res)
165
166 if (exists(req.body?.privacy)) privacy = req.body.privacy
167 else if (exists(video?.privacy)) privacy = video.privacy
168
169 if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true
170
171 if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.')
172
173 const passwords = req.body.videoPasswords || req.body.passwords
174
175 if (passwords.length === 0) return fail('At least one video password is required.')
176
177 if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.')
178
179 for (const password of passwords) {
180 if (typeof password !== 'string') {
181 return fail('Video password should be a string.')
182 }
183
184 if (!isPasswordValid(password)) {
185 return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.')
186 }
187 }
188
189 return true
190}
191
144// --------------------------------------------------------------------------- 192// ---------------------------------------------------------------------------
145 193
146export { 194export {
@@ -164,9 +212,12 @@ export {
164 isVideoDurationValid, 212 isVideoDurationValid,
165 isVideoTagValid, 213 isVideoTagValid,
166 isVideoPrivacyValid, 214 isVideoPrivacyValid,
215 isVideoReplayPrivacyValid,
167 isVideoFileResolutionValid, 216 isVideoFileResolutionValid,
168 isVideoFileSizeValid, 217 isVideoFileSizeValid,
169 isVideoImageValid, 218 isVideoImageValid,
170 isVideoSupportValid, 219 isVideoSupportValid,
171 isVideoFilterValid 220 isVideoFilterValid,
221 isPasswordValid,
222 isValidPasswordProtectedPrivacy
172} 223}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index a92fd22d6..e2f34fe16 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
30const LAST_MIGRATION_VERSION = 780 30const LAST_MIGRATION_VERSION = 785
31 31
32// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
33 33
@@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = {
76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], 76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
77 VIDEO_COMMENTS: [ 'createdAt' ], 77 VIDEO_COMMENTS: [ 'createdAt' ],
78 78
79 VIDEO_PASSWORDS: [ 'createdAt' ],
80
79 VIDEO_RATES: [ 'createdAt' ], 81 VIDEO_RATES: [ 'createdAt' ],
80 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], 82 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
81 83
@@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = {
444 REASON: { min: 1, max: 5000 }, // Length 446 REASON: { min: 1, max: 5000 }, // Length
445 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length 447 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
446 PROGRESS: { min: 0, max: 100 } // Value 448 PROGRESS: { min: 0, max: 100 } // Value
449 },
450 VIDEO_PASSWORD: {
451 LENGTH: { min: 2, max: 100 }
447 } 452 }
448} 453}
449 454
@@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
520 [VideoPrivacy.PUBLIC]: 'Public', 525 [VideoPrivacy.PUBLIC]: 'Public',
521 [VideoPrivacy.UNLISTED]: 'Unlisted', 526 [VideoPrivacy.UNLISTED]: 'Unlisted',
522 [VideoPrivacy.PRIVATE]: 'Private', 527 [VideoPrivacy.PRIVATE]: 'Private',
523 [VideoPrivacy.INTERNAL]: 'Internal' 528 [VideoPrivacy.INTERNAL]: 'Internal',
529 [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
524} 530}
525 531
526const VIDEO_STATES: { [ id in VideoState ]: string } = { 532const VIDEO_STATES: { [ id in VideoState ]: string } = {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 14dd8c379..9e926c26c 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -56,6 +56,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
56import { VideoTagModel } from '../models/video/video-tag' 56import { VideoTagModel } from '../models/video/video-tag'
57import { VideoViewModel } from '../models/view/video-view' 57import { VideoViewModel } from '../models/view/video-view'
58import { CONFIG } from './config' 58import { CONFIG } from './config'
59import { VideoPasswordModel } from '@server/models/video/video-password'
59 60
60require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 61require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
61 62
@@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) {
163 VideoJobInfoModel, 164 VideoJobInfoModel,
164 VideoChannelSyncModel, 165 VideoChannelSyncModel,
165 UserRegistrationModel, 166 UserRegistrationModel,
167 VideoPasswordModel,
166 RunnerRegistrationTokenModel, 168 RunnerRegistrationTokenModel,
167 RunnerModel, 169 RunnerModel,
168 RunnerJobModel 170 RunnerJobModel
diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts
new file mode 100644
index 000000000..1d85f4489
--- /dev/null
+++ b/server/initializers/migrations/0785-video-password-protection.ts
@@ -0,0 +1,31 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 {
9 const query = `
10 CREATE TABLE IF NOT EXISTS "videoPassword" (
11 "id" SERIAL,
12 "password" VARCHAR(255) NOT NULL,
13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
16 PRIMARY KEY ("id")
17 );
18 `
19
20 await utils.sequelize.query(query, { transaction : utils.transaction })
21 }
22}
23
24function down (options) {
25 throw new Error('Not implemented.')
26}
27
28export {
29 up,
30 down
31}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 18b16bee1..be6df1792 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity'
32import { getBiggestActorImage } from './actor-image' 32import { getBiggestActorImage } from './actor-image'
33import { Hooks } from './plugins/hooks' 33import { Hooks } from './plugins/hooks'
34import { ServerConfigManager } from './server-config-manager' 34import { ServerConfigManager } from './server-config-manager'
35import { isVideoInPrivateDirectory } from './video-privacy'
35 36
36type Tags = { 37type Tags = {
37 ogType: string 38 ogType: string
@@ -106,7 +107,7 @@ class ClientHtml {
106 ]) 107 ])
107 108
108 // Let Angular application handle errors 109 // Let Angular application handle errors
109 if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { 110 if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
110 res.status(HttpStatusCode.NOT_FOUND_404) 111 res.status(HttpStatusCode.NOT_FOUND_404)
111 return html 112 return html
112 } 113 }
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
index df67dc953..0ac667ba3 100644
--- a/server/lib/video-pre-import.ts
+++ b/server/lib/video-pre-import.ts
@@ -30,6 +30,7 @@ import {
30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' 30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
31import { getLocalVideoActivityPubUrl } from './activitypub/url' 31import { getLocalVideoActivityPubUrl } from './activitypub/url'
32import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' 32import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
33import { VideoPasswordModel } from '@server/models/video/video-password'
33 34
34class YoutubeDlImportError extends Error { 35class YoutubeDlImportError extends Error {
35 code: YoutubeDlImportError.CODE 36 code: YoutubeDlImportError.CODE
@@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
64 tags: string[] 65 tags: string[]
65 videoImportAttributes: FilteredModelAttributes<VideoImportModel> 66 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
66 user: MUser 67 user: MUser
68 videoPasswords?: string[]
67}): Promise<MVideoImportFormattable> { 69}): Promise<MVideoImportFormattable> {
68 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters 70 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
69 71
70 const videoImport = await sequelizeTypescript.transaction(async t => { 72 const videoImport = await sequelizeTypescript.transaction(async t => {
71 const sequelizeOptions = { transaction: t } 73 const sequelizeOptions = { transaction: t }
@@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
77 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) 79 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
78 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) 80 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
79 81
82 if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
83 await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
84 }
85
80 await autoBlacklistVideoIfNeeded({ 86 await autoBlacklistVideoIfNeeded({
81 video: videoCreated, 87 video: videoCreated,
82 user, 88 user,
@@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
208 state: VideoImportState.PENDING, 214 state: VideoImportState.PENDING,
209 userId: user.id, 215 userId: user.id,
210 videoChannelSyncId: channelSync?.id 216 videoChannelSyncId: channelSync?.id
211 } 217 },
218 videoPasswords: importDataOverride.videoPasswords
212 }) 219 })
213 220
214 // Get video subtitles 221 // Get video subtitles
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts
index 41f9d62b3..39430ef1e 100644
--- a/server/lib/video-privacy.ts
+++ b/server/lib/video-privacy.ts
@@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { VideoPrivacy, VideoStorage } from '@shared/models' 6import { VideoPrivacy, VideoStorage } from '@shared/models'
7import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' 7import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
8 8
9const validPrivacySet = new Set([
10 VideoPrivacy.PRIVATE,
11 VideoPrivacy.INTERNAL,
12 VideoPrivacy.PASSWORD_PROTECTED
13])
14
9function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { 15function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
10 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { 16 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
11 video.publishedAt = new Date() 17 video.publishedAt = new Date()
@@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
14 video.privacy = newPrivacy 20 video.privacy = newPrivacy
15} 21}
16 22
17function isVideoInPrivateDirectory (privacy: VideoPrivacy) { 23function isVideoInPrivateDirectory (privacy) {
18 return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL 24 return validPrivacySet.has(privacy)
19} 25}
20 26
21function isVideoInPublicDirectory (privacy: VideoPrivacy) { 27function isVideoInPublicDirectory (privacy: VideoPrivacy) {
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index 660533528..e28e55cf7 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -12,26 +12,34 @@ class VideoTokensManager {
12 12
13 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
14 14
15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
18 }) 18 })
19 19
20 private constructor () {} 20 private constructor () {}
21 21
22 create (options: { 22 createForAuthUser (options: {
23 user: MUserAccountUrl 23 user: MUserAccountUrl
24 videoUUID: string 24 videoUUID: string
25 }) { 25 }) {
26 const token = buildUUID() 26 const { token, expires } = this.generateVideoToken()
27
28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
29 27
30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) 28 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
31 29
32 return { token, expires } 30 return { token, expires }
33 } 31 }
34 32
33 createForPasswordProtectedVideo (options: {
34 videoUUID: string
35 }) {
36 const { token, expires } = this.generateVideoToken()
37
38 this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
39
40 return { token, expires }
41 }
42
35 hasToken (options: { 43 hasToken (options: {
36 token: string 44 token: string
37 videoUUID: string 45 videoUUID: string
@@ -54,6 +62,13 @@ class VideoTokensManager {
54 static get Instance () { 62 static get Instance () {
55 return this.instance || (this.instance = new this()) 63 return this.instance || (this.instance = new this())
56 } 64 }
65
66 private generateVideoToken () {
67 const token = buildUUID()
68 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
69
70 return { token, expires }
71 }
57} 72}
58 73
59// --------------------------------------------------------------------------- 74// ---------------------------------------------------------------------------
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts
index 0eefa2a8e..39a7b2998 100644
--- a/server/middlewares/auth.ts
+++ b/server/middlewares/auth.ts
@@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner'
5import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
6import { logger } from '../helpers/logger' 6import { logger } from '../helpers/logger'
7import { handleOAuthAuthenticate } from '../lib/auth/oauth' 7import { handleOAuthAuthenticate } from '../lib/auth/oauth'
8import { ServerErrorCode } from '@shared/models'
8 9
9function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 10function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
10 handleOAuthAuthenticate(req, res) 11 handleOAuthAuthenticate(req, res)
@@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
48 .catch(err => logger.error('Cannot get access token.', { err })) 49 .catch(err => logger.error('Cannot get access token.', { err }))
49} 50}
50 51
51function authenticatePromise (req: express.Request, res: express.Response) { 52function authenticatePromise (options: {
53 req: express.Request
54 res: express.Response
55 errorMessage?: string
56 errorStatus?: HttpStatusCode
57 errorType?: ServerErrorCode
58}) {
59 const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
52 return new Promise<void>(resolve => { 60 return new Promise<void>(resolve => {
53 // Already authenticated? (or tried to) 61 // Already authenticated? (or tried to)
54 if (res.locals.oauth?.token.User) return resolve() 62 if (res.locals.oauth?.token.User) return resolve()
55 63
56 if (res.locals.authenticated === false) { 64 if (res.locals.authenticated === false) {
57 return res.fail({ 65 return res.fail({
58 status: HttpStatusCode.UNAUTHORIZED_401, 66 status: errorStatus,
59 message: 'Not authenticated' 67 type: errorType,
68 message: errorMessage
60 }) 69 })
61 } 70 }
62 71
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index de98cd442..e5cff2dda 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -10,4 +10,5 @@ export * from './video-comments'
10export * from './video-imports' 10export * from './video-imports'
11export * from './video-ownerships' 11export * from './video-ownerships'
12export * from './video-playlists' 12export * from './video-playlists'
13export * from './video-passwords'
13export * from './videos' 14export * from './videos'
diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts
new file mode 100644
index 000000000..efcc95dc4
--- /dev/null
+++ b/server/middlewares/validators/shared/video-passwords.ts
@@ -0,0 +1,80 @@
1import express from 'express'
2import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models'
3import { forceNumber } from '@shared/core-utils'
4import { VideoPasswordModel } from '@server/models/video/video-password'
5import { header } from 'express-validator'
6import { getVideoWithAttributes } from '@server/helpers/video'
7
8function isValidVideoPasswordHeader () {
9 return header('x-peertube-video-password')
10 .optional()
11 .isString()
12}
13
14function checkVideoIsPasswordProtected (res: express.Response) {
15 const video = getVideoWithAttributes(res)
16 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
17 res.fail({
18 status: HttpStatusCode.BAD_REQUEST_400,
19 message: 'Video is not password protected'
20 })
21 return false
22 }
23
24 return true
25}
26
27async function doesVideoPasswordExist (idArg: number | string, res: express.Response) {
28 const video = getVideoWithAttributes(res)
29 const id = forceNumber(idArg)
30 const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
31
32 if (!videoPassword) {
33 res.fail({
34 status: HttpStatusCode.NOT_FOUND_404,
35 message: 'Video password not found'
36 })
37 return false
38 }
39
40 res.locals.videoPassword = videoPassword
41
42 return true
43}
44
45async function isVideoPasswordDeletable (res: express.Response) {
46 const user = res.locals.oauth.token.User
47 const userAccount = user.Account
48 const video = res.locals.videoAll
49
50 // Check if the user who did the request is able to delete the video passwords
51 if (
52 user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator
53 video.VideoChannel.accountId !== userAccount.id // Not the video owner
54 ) {
55 res.fail({
56 status: HttpStatusCode.FORBIDDEN_403,
57 message: 'Cannot remove passwords of another user\'s video'
58 })
59 return false
60 }
61
62 const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
63
64 if (passwordCount <= 1) {
65 res.fail({
66 status: HttpStatusCode.BAD_REQUEST_400,
67 message: 'Cannot delete the last password of the protected video'
68 })
69 return false
70 }
71
72 return true
73}
74
75export {
76 isValidVideoPasswordHeader,
77 checkVideoIsPasswordProtected as isVideoPasswordProtected,
78 doesVideoPasswordExist,
79 isVideoPasswordDeletable
80}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index 0033a32ff..9a7497007 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -20,6 +20,8 @@ import {
20 MVideoWithRights 20 MVideoWithRights
21} from '@server/types/models' 21} from '@server/types/models'
22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' 22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { 26async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
25 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 27 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: {
111}) { 113}) {
112 const { req, res, video, paramId } = options 114 const { req, res, video, paramId } = options
113 115
114 if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { 116 if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
115 return checkCanSeeAuthVideo(req, res, video) 117 return checkCanSeeUserAuthVideo({ req, res, video })
118 }
119
120 if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
121 return checkCanSeePasswordProtectedVideo({ req, res, video })
116 } 122 }
117 123
118 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { 124 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: {
122 throw new Error('Unknown video privacy when checking video right ' + video.url) 128 throw new Error('Unknown video privacy when checking video right ' + video.url)
123} 129}
124 130
125async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { 131async function checkCanSeeUserAuthVideo (options: {
132 req: Request
133 res: Response
134 video: MVideoId | MVideoWithRights
135}) {
136 const { req, res, video } = options
137
126 const fail = () => { 138 const fail = () => {
127 res.fail({ 139 res.fail({
128 status: HttpStatusCode.FORBIDDEN_403, 140 status: HttpStatusCode.FORBIDDEN_403,
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
132 return false 144 return false
133 } 145 }
134 146
135 await authenticatePromise(req, res) 147 await authenticatePromise({ req, res })
136 148
137 const user = res.locals.oauth?.token.User 149 const user = res.locals.oauth?.token.User
138 if (!user) return fail() 150 if (!user) return fail()
139 151
140 const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId 152 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
141 ? video as MVideoWithRights
142 : await VideoModel.loadFull(video.id)
143 153
144 const privacy = videoWithRights.privacy 154 const privacy = videoWithRights.privacy
145 155
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
148 return true 158 return true
149 } 159 }
150 160
151 const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
152
153 if (videoWithRights.isBlacklisted()) { 161 if (videoWithRights.isBlacklisted()) {
154 if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true 162 if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true
155 163
156 return fail() 164 return fail()
157 } 165 }
158 166
159 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { 167 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
160 if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true 168 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
161 169
162 return fail() 170 return fail()
163 } 171 }
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
166 return fail() 174 return fail()
167} 175}
168 176
177async function checkCanSeePasswordProtectedVideo (options: {
178 req: Request
179 res: Response
180 video: MVideo
181}) {
182 const { req, res, video } = options
183
184 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
185
186 const videoPassword = req.header('x-peertube-video-password')
187
188 if (!exists(videoPassword)) {
189 const errorMessage = 'Please provide a password to access this password protected video'
190 const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
191
192 if (req.header('authorization')) {
193 await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType })
194 const user = res.locals.oauth?.token.User
195
196 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
197 }
198
199 res.fail({
200 status: HttpStatusCode.FORBIDDEN_403,
201 type: errorType,
202 message: errorMessage
203 })
204 return false
205 }
206
207 if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
208
209 res.fail({
210 status: HttpStatusCode.FORBIDDEN_403,
211 type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
212 message: 'Incorrect video password. Access to the video is denied.'
213 })
214
215 return false
216}
217
218function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) {
219 const isOwnedByUser = video.VideoChannel.Account.userId === user.id
220
221 return isOwnedByUser || user.hasRight(right)
222}
223
224async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
225 return video.VideoChannel?.Account?.userId
226 ? video
227 : VideoModel.loadFull(video.id)
228}
229
169// --------------------------------------------------------------------------- 230// ---------------------------------------------------------------------------
170 231
171async function checkCanAccessVideoStaticFiles (options: { 232async function checkCanAccessVideoStaticFiles (options: {
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
176}) { 237}) {
177 const { video, req, res } = options 238 const { video, req, res } = options
178 239
179 if (res.locals.oauth?.token.User) { 240 if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) {
180 return checkCanSeeVideo(options) 241 return checkCanSeeVideo(options)
181 } 242 }
182 243
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 959f663ac..07d6cba82 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) 28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) 29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) 30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
31 32
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) 33export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) 34export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts
index 9c2d890ba..36a94080c 100644
--- a/server/middlewares/validators/static.ts
+++ b/server/middlewares/validators/static.ts
@@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video'
9import { VideoFileModel } from '@server/models/video/video-file' 9import { VideoFileModel } from '@server/models/video/video-file'
10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' 10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
11import { HttpStatusCode } from '@shared/models' 11import { HttpStatusCode } from '@shared/models'
12import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' 12import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
13 13
14type LRUValue = { 14type LRUValue = {
15 allowed: boolean 15 allowed: boolean
@@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({
25const ensureCanAccessVideoPrivateWebTorrentFiles = [ 25const ensureCanAccessVideoPrivateWebTorrentFiles = [
26 query('videoFileToken').optional().custom(exists), 26 query('videoFileToken').optional().custom(exists),
27 27
28 isValidVideoPasswordHeader(),
29
28 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 30 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
29 if (areValidationErrors(req, res)) return 31 if (areValidationErrors(req, res)) return
30 32
@@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
73 .optional() 75 .optional()
74 .customSanitizer(isSafePeerTubeFilenameWithoutExtension), 76 .customSanitizer(isSafePeerTubeFilenameWithoutExtension),
75 77
78 isValidVideoPasswordHeader(),
79
76 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
77 if (areValidationErrors(req, res)) return 81 if (areValidationErrors(req, res)) return
78 82
@@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU
167} 171}
168 172
169function extractTokenOrDie (req: express.Request, res: express.Response) { 173function extractTokenOrDie (req: express.Request, res: express.Response) {
170 const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken 174 const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
171 175
172 if (!token) { 176 if (!token) {
173 return res.fail({ 177 return res.fail({
174 message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', 178 message: 'Video password header, video file token query parameter and bearer token are all missing', //
175 status: HttpStatusCode.FORBIDDEN_403 179 status: HttpStatusCode.FORBIDDEN_403
176 }) 180 })
177 } 181 }
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index d225dfe45..0c824c314 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -12,6 +12,8 @@ export * from './video-shares'
12export * from './video-source' 12export * from './video-source'
13export * from './video-stats' 13export * from './video-stats'
14export * from './video-studio' 14export * from './video-studio'
15export * from './video-token'
15export * from './video-transcoding' 16export * from './video-transcoding'
16export * from './videos' 17export * from './videos'
17export * from './video-channel-sync' 18export * from './video-channel-sync'
19export * from './video-passwords'
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 72b2febc3..077a58d2e 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -10,7 +10,8 @@ import {
10 checkUserCanManageVideo, 10 checkUserCanManageVideo,
11 doesVideoCaptionExist, 11 doesVideoCaptionExist,
12 doesVideoExist, 12 doesVideoExist,
13 isValidVideoIdParam 13 isValidVideoIdParam,
14 isValidVideoPasswordHeader
14} from '../shared' 15} from '../shared'
15 16
16const addVideoCaptionValidator = [ 17const addVideoCaptionValidator = [
@@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
62const listVideoCaptionsValidator = [ 63const listVideoCaptionsValidator = [
63 isValidVideoIdParam('videoId'), 64 isValidVideoIdParam('videoId'),
64 65
66 isValidVideoPasswordHeader(),
67
65 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 68 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
66 if (areValidationErrors(req, res)) return 69 if (areValidationErrors(req, res)) return
67 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 70 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 133feb7bd..70689b02e 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -14,7 +14,8 @@ import {
14 doesVideoCommentExist, 14 doesVideoCommentExist,
15 doesVideoCommentThreadExist, 15 doesVideoCommentThreadExist,
16 doesVideoExist, 16 doesVideoExist,
17 isValidVideoIdParam 17 isValidVideoIdParam,
18 isValidVideoPasswordHeader
18} from '../shared' 19} from '../shared'
19 20
20const listVideoCommentsValidator = [ 21const listVideoCommentsValidator = [
@@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
51 52
52const listVideoCommentThreadsValidator = [ 53const listVideoCommentThreadsValidator = [
53 isValidVideoIdParam('videoId'), 54 isValidVideoIdParam('videoId'),
55 isValidVideoPasswordHeader(),
54 56
55 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 57 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
56 if (areValidationErrors(req, res)) return 58 if (areValidationErrors(req, res)) return
@@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
67 69
68 param('threadId') 70 param('threadId')
69 .custom(isIdValid), 71 .custom(isIdValid),
72 isValidVideoPasswordHeader(),
70 73
71 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 if (areValidationErrors(req, res)) return 75 if (areValidationErrors(req, res)) return
@@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
84 87
85 body('text') 88 body('text')
86 .custom(isValidVideoCommentText), 89 .custom(isValidVideoCommentText),
90 isValidVideoPasswordHeader(),
87 91
88 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 92 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
89 if (areValidationErrors(req, res)) return 93 if (areValidationErrors(req, res)) return
@@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
102 isValidVideoIdParam('videoId'), 106 isValidVideoIdParam('videoId'),
103 107
104 param('commentId').custom(isIdValid), 108 param('commentId').custom(isIdValid),
109 isValidVideoPasswordHeader(),
105 110
106 body('text').custom(isValidVideoCommentText), 111 body('text').custom(isValidVideoCommentText),
107 112
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index 72442aeb6..a1cb65b70 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' 9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
12import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' 12import {
13 isValidPasswordProtectedPrivacy,
14 isVideoMagnetUriValid,
15 isVideoNameValid
16} from '../../../helpers/custom-validators/videos'
13import { cleanUpReqFiles } from '../../../helpers/express-utils' 17import { cleanUpReqFiles } from '../../../helpers/express-utils'
14import { logger } from '../../../helpers/logger' 18import { logger } from '../../../helpers/logger'
15import { CONFIG } from '../../../initializers/config' 19import { CONFIG } from '../../../initializers/config'
@@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
38 .custom(isVideoNameValid).withMessage( 42 .custom(isVideoNameValid).withMessage(
39 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` 43 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
40 ), 44 ),
45 body('videoPasswords')
46 .optional()
47 .isArray()
48 .withMessage('Video passwords should be an array.'),
41 49
42 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 50 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
43 const user = res.locals.oauth.token.User 51 const user = res.locals.oauth.token.User
@@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
45 53
46 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 54 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
47 55
56 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
57
48 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { 58 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
49 cleanUpReqFiles(req) 59 cleanUpReqFiles(req)
50 60
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 2aff831a8..ec69a3011 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -17,7 +17,7 @@ import {
17 VideoState 17 VideoState
18} from '@shared/models' 18} from '@shared/models'
19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
20import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos' 20import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos'
21import { cleanUpReqFiles } from '../../../helpers/express-utils' 21import { cleanUpReqFiles } from '../../../helpers/express-utils'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { CONFIG } from '../../../initializers/config' 23import { CONFIG } from '../../../initializers/config'
@@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
69 body('replaySettings.privacy') 69 body('replaySettings.privacy')
70 .optional() 70 .optional()
71 .customSanitizer(toIntOrNull) 71 .customSanitizer(toIntOrNull)
72 .custom(isVideoPrivacyValid), 72 .custom(isVideoReplayPrivacyValid),
73 73
74 body('permanentLive') 74 body('permanentLive')
75 .optional() 75 .optional()
@@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
81 .customSanitizer(toIntOrNull) 81 .customSanitizer(toIntOrNull)
82 .custom(isLiveLatencyModeValid), 82 .custom(isLiveLatencyModeValid),
83 83
84 body('videoPasswords')
85 .optional()
86 .isArray()
87 .withMessage('Video passwords should be an array.'),
88
84 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 89 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
85 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 90 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
86 91
92 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
93
87 if (CONFIG.LIVE.ENABLED !== true) { 94 if (CONFIG.LIVE.ENABLED !== true) {
88 cleanUpReqFiles(req) 95 cleanUpReqFiles(req)
89 96
@@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
170 body('replaySettings.privacy') 177 body('replaySettings.privacy')
171 .optional() 178 .optional()
172 .customSanitizer(toIntOrNull) 179 .customSanitizer(toIntOrNull)
173 .custom(isVideoPrivacyValid), 180 .custom(isVideoReplayPrivacyValid),
174 181
175 body('latencyMode') 182 body('latencyMode')
176 .optional() 183 .optional()
diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts
new file mode 100644
index 000000000..200e496f6
--- /dev/null
+++ b/server/middlewares/validators/videos/video-passwords.ts
@@ -0,0 +1,77 @@
1import express from 'express'
2import {
3 areValidationErrors,
4 doesVideoExist,
5 isVideoPasswordProtected,
6 isValidVideoIdParam,
7 doesVideoPasswordExist,
8 isVideoPasswordDeletable,
9 checkUserCanManageVideo
10} from '../shared'
11import { body, param } from 'express-validator'
12import { isIdValid } from '@server/helpers/custom-validators/misc'
13import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos'
14import { UserRight } from '@shared/models'
15
16const listVideoPasswordValidator = [
17 isValidVideoIdParam('videoId'),
18
19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
20 if (areValidationErrors(req, res)) return
21
22 if (!await doesVideoExist(req.params.videoId, res)) return
23 if (!isVideoPasswordProtected(res)) return
24
25 // Check if the user who did the request is able to access video password list
26 const user = res.locals.oauth.token.User
27 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return
28
29 return next()
30 }
31]
32
33const updateVideoPasswordListValidator = [
34 body('passwords')
35 .optional()
36 .isArray()
37 .withMessage('Video passwords should be an array.'),
38
39 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
40 if (areValidationErrors(req, res)) return
41
42 if (!await doesVideoExist(req.params.videoId, res)) return
43 if (!isValidPasswordProtectedPrivacy(req, res)) return
44
45 // Check if the user who did the request is able to update video passwords
46 const user = res.locals.oauth.token.User
47 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
48
49 return next()
50 }
51]
52
53const removeVideoPasswordValidator = [
54 isValidVideoIdParam('videoId'),
55
56 param('passwordId')
57 .custom(isIdValid),
58
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 if (areValidationErrors(req, res)) return
61
62 if (!await doesVideoExist(req.params.videoId, res)) return
63 if (!isVideoPasswordProtected(res)) return
64 if (!await doesVideoPasswordExist(req.params.passwordId, res)) return
65 if (!await isVideoPasswordDeletable(res)) return
66
67 return next()
68 }
69]
70
71// ---------------------------------------------------------------------------
72
73export {
74 listVideoPasswordValidator,
75 updateVideoPasswordListValidator,
76 removeVideoPasswordValidator
77}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index c631a16f8..95a5ba63a 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
153 } 153 }
154 154
155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { 155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
156 await authenticatePromise(req, res) 156 await authenticatePromise({ req, res })
157 157
158 const user = res.locals.oauth ? res.locals.oauth.token.User : null 158 const user = res.locals.oauth ? res.locals.oauth.token.User : null
159 159
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 275634d5b..c837b047b 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc'
7import { isRatingValid } from '../../../helpers/custom-validators/video-rates' 7import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' 8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
9import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 9import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' 10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared'
11 11
12const videoUpdateRateValidator = [ 12const videoUpdateRateValidator = [
13 isValidVideoIdParam('id'), 13 isValidVideoIdParam('id'),
14 14
15 body('rating') 15 body('rating')
16 .custom(isVideoRatingTypeValid), 16 .custom(isVideoRatingTypeValid),
17 isValidVideoPasswordHeader(),
17 18
18 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
19 if (areValidationErrors(req, res)) return 20 if (areValidationErrors(req, res)) return
diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts
new file mode 100644
index 000000000..d4253e21d
--- /dev/null
+++ b/server/middlewares/validators/videos/video-token.ts
@@ -0,0 +1,24 @@
1import express from 'express'
2import { VideoPrivacy } from '../../../../shared/models/videos'
3import { HttpStatusCode } from '@shared/models'
4import { exists } from '@server/helpers/custom-validators/misc'
5
6const videoFileTokenValidator = [
7 (req: express.Request, res: express.Response, next: express.NextFunction) => {
8 const video = res.locals.onlyVideo
9 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
10 return res.fail({
11 status: HttpStatusCode.UNAUTHORIZED_401,
12 message: 'Not authenticated'
13 })
14 }
15
16 return next()
17 }
18]
19
20// ---------------------------------------------------------------------------
21
22export {
23 videoFileTokenValidator
24}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 794e1d4f1..7f1f39b11 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../
23import { 23import {
24 areVideoTagsValid, 24 areVideoTagsValid,
25 isScheduleVideoUpdatePrivacyValid, 25 isScheduleVideoUpdatePrivacyValid,
26 isValidPasswordProtectedPrivacy,
26 isVideoCategoryValid, 27 isVideoCategoryValid,
27 isVideoDescriptionValid, 28 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid, 29 isVideoFileMimeTypeValid,
@@ -55,7 +56,8 @@ import {
55 doesVideoChannelOfAccountExist, 56 doesVideoChannelOfAccountExist,
56 doesVideoExist, 57 doesVideoExist,
57 doesVideoFileOfVideoExist, 58 doesVideoFileOfVideoExist,
58 isValidVideoIdParam 59 isValidVideoIdParam,
60 isValidVideoPasswordHeader
59} from '../shared' 61} from '../shared'
60 62
61const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 63const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
@@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
70 body('channelId') 72 body('channelId')
71 .customSanitizer(toIntOrNull) 73 .customSanitizer(toIntOrNull)
72 .custom(isIdValid), 74 .custom(isIdValid),
75 body('videoPasswords')
76 .optional()
77 .isArray()
78 .withMessage('Video passwords should be an array.'),
73 79
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 81 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
@@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
81 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
82 } 88 }
83 89
90 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
91
84 try { 92 try {
85 if (!videoFile.duration) await addDurationToVideo(videoFile) 93 if (!videoFile.duration) await addDurationToVideo(videoFile)
86 } catch (err) { 94 } catch (err) {
@@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
174 body('channelId') 182 body('channelId')
175 .customSanitizer(toIntOrNull) 183 .customSanitizer(toIntOrNull)
176 .custom(isIdValid), 184 .custom(isIdValid),
185 body('videoPasswords')
186 .optional()
187 .isArray()
188 .withMessage('Video passwords should be an array.'),
177 189
178 header('x-upload-content-length') 190 header('x-upload-content-length')
179 .isNumeric() 191 .isNumeric()
@@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
205 const files = { videofile: [ videoFileMetadata ] } 217 const files = { videofile: [ videoFileMetadata ] }
206 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() 218 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
207 219
220 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
221
208 // multer required unsetting the Content-Type, now we can set it for node-uploadx 222 // multer required unsetting the Content-Type, now we can set it for node-uploadx
209 req.headers['content-type'] = 'application/json; charset=utf-8' 223 req.headers['content-type'] = 'application/json; charset=utf-8'
210 // place previewfile in metadata so that uploadx saves it in .META 224 // place previewfile in metadata so that uploadx saves it in .META
@@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
227 .optional() 241 .optional()
228 .customSanitizer(toIntOrNull) 242 .customSanitizer(toIntOrNull)
229 .custom(isIdValid), 243 .custom(isIdValid),
244 body('videoPasswords')
245 .optional()
246 .isArray()
247 .withMessage('Video passwords should be an array.'),
230 248
231 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 249 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
232 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 250 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
233 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) 251 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
234 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) 252 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
235 253
254 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
255
236 const video = getVideoWithAttributes(res) 256 const video = getVideoWithAttributes(res)
237 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { 257 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
238 return res.fail({ message: 'Cannot update privacy of a live that has already started' }) 258 return res.fail({ message: 'Cannot update privacy of a live that has already started' })
@@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
281 return [ 301 return [
282 isValidVideoIdParam('id'), 302 isValidVideoIdParam('id'),
283 303
304 isValidVideoPasswordHeader(),
305
284 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 306 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
285 if (areValidationErrors(req, res)) return 307 if (areValidationErrors(req, res)) return
286 if (!await doesVideoExist(req.params.id, res, fetchType)) return 308 if (!await doesVideoExist(req.params.id, res, fetchType)) return
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
new file mode 100644
index 000000000..648366c3b
--- /dev/null
+++ b/server/models/video/video-password.ts
@@ -0,0 +1,137 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from './video'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { ResultList, VideoPassword } from '@shared/models'
5import { getSort, throwIfNotValid } from '../shared'
6import { FindOptions, Transaction } from 'sequelize'
7import { MVideoPassword } from '@server/types/models'
8import { isPasswordValid } from '@server/helpers/custom-validators/videos'
9import { pick } from '@shared/core-utils'
10
11@DefaultScope(() => ({
12 include: [
13 {
14 model: VideoModel.unscoped(),
15 required: true
16 }
17 ]
18}))
19@Table({
20 tableName: 'videoPassword',
21 indexes: [
22 {
23 fields: [ 'videoId', 'password' ],
24 unique: true
25 }
26 ]
27})
28export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
29
30 @AllowNull(false)
31 @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
32 @Column
33 password: string
34
35 @CreatedAt
36 createdAt: Date
37
38 @UpdatedAt
39 updatedAt: Date
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Video: VideoModel
52
53 static async countByVideoId (videoId: number, t?: Transaction) {
54 const query: FindOptions = {
55 where: {
56 videoId
57 },
58 transaction: t
59 }
60
61 return VideoPasswordModel.count(query)
62 }
63
64 static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
65 const { id, videoId, t } = options
66 const query: FindOptions = {
67 where: {
68 id,
69 videoId
70 },
71 transaction: t
72 }
73
74 return VideoPasswordModel.findOne(query)
75 }
76
77 static async listPasswords (options: {
78 start: number
79 count: number
80 sort: string
81 videoId: number
82 }): Promise<ResultList<MVideoPassword>> {
83 const { start, count, sort, videoId } = options
84
85 const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
86 where: { videoId },
87 order: getSort(sort),
88 offset: start,
89 limit: count
90 })
91
92 return { total, data }
93 }
94
95 static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
96 for (const password of passwords) {
97 await VideoPasswordModel.create({
98 password,
99 videoId
100 }, { transaction })
101 }
102 }
103
104 static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
105 await VideoPasswordModel.destroy({
106 where: { videoId },
107 transaction
108 })
109 }
110
111 static async deletePassword (passwordId: number, transaction?: Transaction) {
112 await VideoPasswordModel.destroy({
113 where: { id: passwordId },
114 transaction
115 })
116 }
117
118 static async isACorrectPassword (options: {
119 videoId: number
120 password: string
121 }) {
122 const query = {
123 where: pick(options, [ 'videoId', 'password' ])
124 }
125 return VideoPasswordModel.findOne(query)
126 }
127
128 toFormattedJSON (): VideoPassword {
129 return {
130 id: this.id,
131 password: this.password,
132 videoId: this.videoId,
133 createdAt: this.createdAt,
134 updatedAt: this.updatedAt
135 }
136 }
137}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index b832f9768..61ae6b9fe 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
336 // Internal video? 336 // Internal video?
337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR 337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
338 338
339 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE 339 // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
340 if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
341 return VideoPlaylistElementType.PRIVATE
342 }
340 343
341 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE 344 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
342 345
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 8e3af62a4..f90f2b7f6 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -136,6 +136,7 @@ import { VideoFileModel } from './video-file'
136import { VideoImportModel } from './video-import' 136import { VideoImportModel } from './video-import'
137import { VideoJobInfoModel } from './video-job-info' 137import { VideoJobInfoModel } from './video-job-info'
138import { VideoLiveModel } from './video-live' 138import { VideoLiveModel } from './video-live'
139import { VideoPasswordModel } from './video-password'
139import { VideoPlaylistElementModel } from './video-playlist-element' 140import { VideoPlaylistElementModel } from './video-playlist-element'
140import { VideoShareModel } from './video-share' 141import { VideoShareModel } from './video-share'
141import { VideoSourceModel } from './video-source' 142import { VideoSourceModel } from './video-source'
@@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
734 }) 735 })
735 VideoCaptions: VideoCaptionModel[] 736 VideoCaptions: VideoCaptionModel[]
736 737
738 @HasMany(() => VideoPasswordModel, {
739 foreignKey: {
740 name: 'videoId',
741 allowNull: false
742 },
743 onDelete: 'cascade'
744 })
745 VideoPasswords: VideoPasswordModel[]
746
737 @HasOne(() => VideoJobInfoModel, { 747 @HasOne(() => VideoJobInfoModel, {
738 foreignKey: { 748 foreignKey: {
739 name: 'videoId', 749 name: 'videoId',
@@ -1918,7 +1928,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1918 1928
1919 // --------------------------------------------------------------------------- 1929 // ---------------------------------------------------------------------------
1920 1930
1921 requiresAuth (options: { 1931 requiresUserAuth (options: {
1922 urlParamId: string 1932 urlParamId: string
1923 checkBlacklist: boolean 1933 checkBlacklist: boolean
1924 }) { 1934 }) {
@@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1936 1946
1937 if (checkBlacklist && this.VideoBlacklist) return true 1947 if (checkBlacklist && this.VideoBlacklist) return true
1938 1948
1939 if (this.privacy !== VideoPrivacy.PUBLIC) { 1949 if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
1940 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) 1950 return false
1941 } 1951 }
1942 1952
1943 return false 1953 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1944 } 1954 }
1945 1955
1946 hasPrivateStaticPath () { 1956 hasPrivateStaticPath () {
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 2dc735c23..406a96824 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -143,7 +143,7 @@ describe('Test video lives API validator', function () {
143 }) 143 })
144 144
145 it('Should fail with a bad privacy for replay settings', async function () { 145 it('Should fail with a bad privacy for replay settings', async function () {
146 const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } } 146 const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } }
147 147
148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
149 }) 149 })
@@ -472,7 +472,7 @@ describe('Test video lives API validator', function () {
472 }) 472 })
473 473
474 it('Should fail with a bad privacy for replay settings', async function () { 474 it('Should fail with a bad privacy for replay settings', async function () {
475 const fields = { saveReplay: true, replaySettings: { privacy: 5 } } 475 const fields = { saveReplay: true, replaySettings: { privacy: 999 } }
476 476
477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
478 }) 478 })
diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts
new file mode 100644
index 000000000..4e936b5d2
--- /dev/null
+++ b/server/tests/api/check-params/video-passwords.ts
@@ -0,0 +1,609 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import {
3 FIXTURE_URLS,
4 checkBadCountPagination,
5 checkBadSortPagination,
6 checkBadStartPagination,
7 checkUploadVideoParam
8} from '@server/tests/shared'
9import { root } from '@shared/core-utils'
10import {
11 HttpStatusCode,
12 PeerTubeProblemDocument,
13 ServerErrorCode,
14 VideoCreateResult,
15 VideoPrivacy
16} from '@shared/models'
17import {
18 cleanupTests,
19 createSingleServer,
20 makePostBodyRequest,
21 PeerTubeServer,
22 setAccessTokensToServers
23} from '@shared/server-commands'
24import { expect } from 'chai'
25import { join } from 'path'
26
27describe('Test video passwords validator', function () {
28 let path: string
29 let server: PeerTubeServer
30 let userAccessToken = ''
31 let video: VideoCreateResult
32 let channelId: number
33 let publicVideo: VideoCreateResult
34 let commentId: number
35 // ---------------------------------------------------------------
36
37 before(async function () {
38 this.timeout(50000)
39
40 server = await createSingleServer(1)
41
42 await setAccessTokensToServers([ server ])
43
44 await server.config.updateCustomSubConfig({
45 newConfig: {
46 live: {
47 enabled: true,
48 latencySetting: {
49 enabled: false
50 },
51 allowReplay: false
52 },
53 import: {
54 videos: {
55 http:{
56 enabled: true
57 }
58 }
59 }
60 }
61 })
62
63 userAccessToken = await server.users.generateUserAndToken('user1')
64
65 {
66 const body = await server.users.getMyInfo()
67 channelId = body.videoChannels[0].id
68 }
69
70 {
71 video = await server.videos.quickUpload({
72 name: 'password protected video',
73 privacy: VideoPrivacy.PASSWORD_PROTECTED,
74 videoPasswords: [ 'password1', 'password2' ]
75 })
76 }
77 path = '/api/v1/videos/'
78 })
79
80 async function checkVideoPasswordOptions (options: {
81 server: PeerTubeServer
82 token: string
83 videoPasswords: string[]
84 expectedStatus: HttpStatusCode
85 mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live'
86 }) {
87 const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options
88 const attaches = {
89 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
90 }
91 const baseCorrectParams = {
92 name: 'my super name',
93 category: 5,
94 licence: 1,
95 language: 'pt',
96 nsfw: false,
97 commentsEnabled: true,
98 downloadEnabled: true,
99 waitTranscoding: true,
100 description: 'my super description',
101 support: 'my super support text',
102 tags: [ 'tag1', 'tag2' ],
103 privacy: VideoPrivacy.PASSWORD_PROTECTED,
104 channelId,
105 originallyPublishedAt: new Date().toISOString()
106 }
107 if (mode === 'uploadLegacy') {
108 const fields = { ...baseCorrectParams, videoPasswords }
109 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy')
110 }
111
112 if (mode === 'uploadResumable') {
113 const fields = { ...baseCorrectParams, videoPasswords }
114 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable')
115 }
116
117 if (mode === 'import') {
118 const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords }
119 return server.imports.importVideo({ attributes, expectedStatus })
120 }
121
122 if (mode === 'updateVideo') {
123 const attributes = { ...baseCorrectParams, videoPasswords }
124 return server.videos.update({ token, expectedStatus, id: video.id, attributes })
125 }
126
127 if (mode === 'updatePasswords') {
128 return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords })
129 }
130
131 if (mode === 'live') {
132 const fields = { ...baseCorrectParams, videoPasswords }
133
134 return server.live.create({ fields, expectedStatus })
135 }
136 }
137
138 function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
139
140 it('Should fail with a password protected privacy without providing a password', async function () {
141 await checkVideoPasswordOptions({
142 server,
143 token: server.accessToken,
144 videoPasswords: undefined,
145 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
146 mode
147 })
148 })
149
150 it('Should fail with a password protected privacy and an empty password list', async function () {
151 const videoPasswords = []
152
153 await checkVideoPasswordOptions({
154 server,
155 token: server.accessToken,
156 videoPasswords,
157 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
158 mode
159 })
160 })
161
162 it('Should fail with a password protected privacy and a too short password', async function () {
163 const videoPasswords = [ 'p' ]
164
165 await checkVideoPasswordOptions({
166 server,
167 token: server.accessToken,
168 videoPasswords,
169 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
170 mode
171 })
172 })
173
174 it('Should fail with a password protected privacy and a too long password', async function () {
175 const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ]
176
177 await checkVideoPasswordOptions({
178 server,
179 token: server.accessToken,
180 videoPasswords,
181 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
182 mode
183 })
184 })
185
186 it('Should fail with a password protected privacy and an empty password', async function () {
187 const videoPasswords = [ '' ]
188
189 await checkVideoPasswordOptions({
190 server,
191 token: server.accessToken,
192 videoPasswords,
193 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
194 mode
195 })
196 })
197
198 it('Should fail with a password protected privacy and duplicated passwords', async function () {
199 const videoPasswords = [ 'password', 'password' ]
200
201 await checkVideoPasswordOptions({
202 server,
203 token: server.accessToken,
204 videoPasswords,
205 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
206 mode
207 })
208 })
209
210 if (mode === 'updatePasswords') {
211 it('Should fail for an unauthenticated user', async function () {
212 const videoPasswords = [ 'password' ]
213 await checkVideoPasswordOptions({
214 server,
215 token: null,
216 videoPasswords,
217 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
218 mode
219 })
220 })
221
222 it('Should fail for an unauthorized user', async function () {
223 const videoPasswords = [ 'password' ]
224 await checkVideoPasswordOptions({
225 server,
226 token: userAccessToken,
227 videoPasswords,
228 expectedStatus: HttpStatusCode.FORBIDDEN_403,
229 mode
230 })
231 })
232 }
233
234 it('Should succeed with a password protected privacy and correct passwords', async function () {
235 const videoPasswords = [ 'password1', 'password2' ]
236 const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo'
237 ? HttpStatusCode.NO_CONTENT_204
238 : HttpStatusCode.OK_200
239
240 await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode })
241 })
242 }
243
244 describe('When adding or updating a video', function () {
245 describe('Resumable upload', function () {
246 validateVideoPasswordList('uploadResumable')
247 })
248
249 describe('Legacy upload', function () {
250 validateVideoPasswordList('uploadLegacy')
251 })
252
253 describe('When importing a video', function () {
254 validateVideoPasswordList('import')
255 })
256
257 describe('When updating a video', function () {
258 validateVideoPasswordList('updateVideo')
259 })
260
261 describe('When updating the password list of a video', function () {
262 validateVideoPasswordList('updatePasswords')
263 })
264
265 describe('When creating a live', function () {
266 validateVideoPasswordList('live')
267 })
268 })
269
270 async function checkVideoAccessOptions (options: {
271 server: PeerTubeServer
272 token?: string
273 videoPassword?: string
274 expectedStatus: HttpStatusCode
275 mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token'
276 }) {
277 const { server, token = null, videoPassword, expectedStatus, mode } = options
278
279 if (mode === 'get') {
280 return server.videos.get({ id: video.id, expectedStatus })
281 }
282
283 if (mode === 'getWithToken') {
284 return server.videos.getWithToken({
285 id: video.id,
286 token,
287 expectedStatus
288 })
289 }
290
291 if (mode === 'getWithPassword') {
292 return server.videos.getWithPassword({
293 id: video.id,
294 token,
295 expectedStatus,
296 password: videoPassword
297 })
298 }
299
300 if (mode === 'rate') {
301 return server.videos.rate({
302 id: video.id,
303 token,
304 expectedStatus,
305 rating: 'like',
306 videoPassword
307 })
308 }
309
310 if (mode === 'createThread') {
311 const fields = { text: 'super comment' }
312 const headers = videoPassword !== undefined && videoPassword !== null
313 ? { 'x-peertube-video-password': videoPassword }
314 : undefined
315 const body = await makePostBodyRequest({
316 url: server.url,
317 path: path + video.uuid + '/comment-threads',
318 token,
319 fields,
320 headers,
321 expectedStatus
322 })
323 return JSON.parse(body.text)
324 }
325
326 if (mode === 'replyThread') {
327 const fields = { text: 'super reply' }
328 const headers = videoPassword !== undefined && videoPassword !== null
329 ? { 'x-peertube-video-password': videoPassword }
330 : undefined
331 return makePostBodyRequest({
332 url: server.url,
333 path: path + video.uuid + '/comments/' + commentId,
334 token,
335 fields,
336 headers,
337 expectedStatus
338 })
339 }
340 if (mode === 'listThreads') {
341 return server.comments.listThreads({
342 videoId: video.id,
343 token,
344 expectedStatus,
345 videoPassword
346 })
347 }
348
349 if (mode === 'listCaptions') {
350 return server.captions.list({
351 videoId: video.id,
352 token,
353 expectedStatus,
354 videoPassword
355 })
356 }
357
358 if (mode === 'token') {
359 return server.videoToken.create({
360 videoId: video.id,
361 token,
362 expectedStatus,
363 videoPassword
364 })
365 }
366 }
367
368 function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') {
369 const serverCode = mode === 'providePassword'
370 ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD
371 : ServerErrorCode.INCORRECT_VIDEO_PASSWORD
372
373 const message = mode === 'providePassword'
374 ? 'Please provide a password to access this password protected video'
375 : 'Incorrect video password. Access to the video is denied.'
376
377 if (!error.code) {
378 error = JSON.parse(error.text)
379 }
380
381 expect(error.code).to.equal(serverCode)
382 expect(error.detail).to.equal(message)
383 expect(error.error).to.equal(message)
384
385 expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
386 }
387
388 function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') {
389 const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
390 let tokens: string[]
391 if (!requiresUserAuth) {
392 it('Should fail without providing a password for an unlogged user', async function () {
393 const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
394 const error = body as unknown as PeerTubeProblemDocument
395
396 checkVideoError(error, 'providePassword')
397 })
398 }
399
400 it('Should fail without providing a password for an unauthorised user', async function () {
401 const tmp = mode === 'get' ? 'getWithToken' : mode
402
403 const body = await checkVideoAccessOptions({
404 server,
405 token: userAccessToken,
406 expectedStatus: HttpStatusCode.FORBIDDEN_403,
407 mode: tmp
408 })
409
410 const error = body as unknown as PeerTubeProblemDocument
411
412 checkVideoError(error, 'providePassword')
413 })
414
415 it('Should fail if a wrong password is entered', async function () {
416 const tmp = mode === 'get' ? 'getWithPassword' : mode
417 tokens = [ userAccessToken, server.accessToken ]
418
419 if (!requiresUserAuth) tokens.push(null)
420
421 for (const token of tokens) {
422 const body = await checkVideoAccessOptions({
423 server,
424 token,
425 videoPassword: 'toto',
426 expectedStatus: HttpStatusCode.FORBIDDEN_403,
427 mode: tmp
428 })
429 const error = body as unknown as PeerTubeProblemDocument
430
431 checkVideoError(error, 'incorrectPassword')
432 }
433 })
434
435 it('Should fail if an empty password is entered', async function () {
436 const tmp = mode === 'get' ? 'getWithPassword' : mode
437
438 for (const token of tokens) {
439 const body = await checkVideoAccessOptions({
440 server,
441 token,
442 videoPassword: '',
443 expectedStatus: HttpStatusCode.FORBIDDEN_403,
444 mode: tmp
445 })
446 const error = body as unknown as PeerTubeProblemDocument
447
448 checkVideoError(error, 'incorrectPassword')
449 }
450 })
451
452 it('Should fail if an inccorect password containing the correct password is entered', async function () {
453 const tmp = mode === 'get' ? 'getWithPassword' : mode
454
455 for (const token of tokens) {
456 const body = await checkVideoAccessOptions({
457 server,
458 token,
459 videoPassword: 'password11',
460 expectedStatus: HttpStatusCode.FORBIDDEN_403,
461 mode: tmp
462 })
463 const error = body as unknown as PeerTubeProblemDocument
464
465 checkVideoError(error, 'incorrectPassword')
466 }
467 })
468
469 it('Should succeed without providing a password for an authorised user', async function () {
470 const tmp = mode === 'get' ? 'getWithToken' : mode
471 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
472
473 const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp })
474
475 if (mode === 'createThread') commentId = body.comment.id
476 })
477
478 it('Should succeed using correct passwords', async function () {
479 const tmp = mode === 'get' ? 'getWithPassword' : mode
480 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
481
482 for (const token of tokens) {
483 await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp })
484 await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp })
485 }
486 })
487 }
488
489 describe('When accessing password protected video', function () {
490
491 describe('For getting a password protected video', function () {
492 validateVideoAccess('get')
493 })
494
495 describe('For rating a video', function () {
496 validateVideoAccess('rate')
497 })
498
499 describe('For creating a thread', function () {
500 validateVideoAccess('createThread')
501 })
502
503 describe('For replying to a thread', function () {
504 validateVideoAccess('replyThread')
505 })
506
507 describe('For listing threads', function () {
508 validateVideoAccess('listThreads')
509 })
510
511 describe('For getting captions', function () {
512 validateVideoAccess('listCaptions')
513 })
514
515 describe('For creating video file token', function () {
516 validateVideoAccess('token')
517 })
518 })
519
520 describe('When listing passwords', function () {
521 it('Should fail with a bad start pagination', async function () {
522 await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
523 })
524
525 it('Should fail with a bad count pagination', async function () {
526 await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
527 })
528
529 it('Should fail with an incorrect sort', async function () {
530 await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
531 })
532
533 it('Should fail for unauthenticated user', async function () {
534 await server.videoPasswords.list({
535 token: null,
536 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
537 videoId: video.id
538 })
539 })
540
541 it('Should fail for unauthorized user', async function () {
542 await server.videoPasswords.list({
543 token: userAccessToken,
544 expectedStatus: HttpStatusCode.FORBIDDEN_403,
545 videoId: video.id
546 })
547 })
548
549 it('Should succeed with the correct parameters', async function () {
550 await server.videoPasswords.list({
551 token: server.accessToken,
552 expectedStatus: HttpStatusCode.OK_200,
553 videoId: video.id
554 })
555 })
556 })
557
558 describe('When deleting a password', async function () {
559 const passwords = (await server.videoPasswords.list({ videoId: video.id })).data
560
561 it('Should fail with wrong password id', async function () {
562 await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
563 })
564
565 it('Should fail for unauthenticated user', async function () {
566 await server.videoPasswords.remove({
567 id: passwords[0].id,
568 token: null,
569 videoId: video.id,
570 expectedStatus: HttpStatusCode.FORBIDDEN_403
571 })
572 })
573
574 it('Should fail for unauthorized user', async function () {
575 await server.videoPasswords.remove({
576 id: passwords[0].id,
577 token: userAccessToken,
578 videoId: video.id,
579 expectedStatus: HttpStatusCode.BAD_REQUEST_400
580 })
581 })
582
583 it('Should fail for non password protected video', async function () {
584 publicVideo = await server.videos.quickUpload({ name: 'public video' })
585 await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
586 })
587
588 it('Should fail for password not linked to correct video', async function () {
589 const video2 = await server.videos.quickUpload({
590 name: 'password protected video',
591 privacy: VideoPrivacy.PASSWORD_PROTECTED,
592 videoPasswords: [ 'password1', 'password2' ]
593 })
594 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
595 })
596
597 it('Should succeed with correct parameter', async function () {
598 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
599 })
600
601 it('Should fail for last password of a video', async function () {
602 await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
603 })
604 })
605
606 after(async function () {
607 await cleanupTests([ server ])
608 })
609})
diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts
index 7acb9d580..7cb3e84a2 100644
--- a/server/tests/api/check-params/video-token.ts
+++ b/server/tests/api/check-params/video-token.ts
@@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
5 5
6describe('Test video tokens', function () { 6describe('Test video tokens', function () {
7 let server: PeerTubeServer 7 let server: PeerTubeServer
8 let videoId: string 8 let privateVideoId: string
9 let passwordProtectedVideoId: string
9 let userToken: string 10 let userToken: string
10 11
12 const videoPassword = 'password'
13
11 // --------------------------------------------------------------- 14 // ---------------------------------------------------------------
12 15
13 before(async function () { 16 before(async function () {
@@ -15,27 +18,50 @@ describe('Test video tokens', function () {
15 18
16 server = await createSingleServer(1) 19 server = await createSingleServer(1)
17 await setAccessTokensToServers([ server ]) 20 await setAccessTokensToServers([ server ])
18 21 {
19 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 22 const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
20 videoId = uuid 23 privateVideoId = uuid
21 24 }
25 {
26 const { uuid } = await server.videos.quickUpload({
27 name: 'password protected video',
28 privacy: VideoPrivacy.PASSWORD_PROTECTED,
29 videoPasswords: [ videoPassword ]
30 })
31 passwordProtectedVideoId = uuid
32 }
22 userToken = await server.users.generateUserAndToken('user1') 33 userToken = await server.users.generateUserAndToken('user1')
23 }) 34 })
24 35
25 it('Should not generate tokens for unauthenticated user', async function () { 36 it('Should not generate tokens on private video for unauthenticated user', async function () {
26 await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 37 await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
27 }) 38 })
28 39
29 it('Should not generate tokens of unknown video', async function () { 40 it('Should not generate tokens of unknown video', async function () {
30 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 41 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
31 }) 42 })
32 43
44 it('Should not generate tokens with incorrect password', async function () {
45 await server.videoToken.create({
46 videoId: passwordProtectedVideoId,
47 token: null,
48 expectedStatus: HttpStatusCode.FORBIDDEN_403,
49 videoPassword: 'incorrectPassword'
50 })
51 })
52
33 it('Should not generate tokens of a non owned video', async function () { 53 it('Should not generate tokens of a non owned video', async function () {
34 await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 54 await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
35 }) 55 })
36 56
37 it('Should generate token', async function () { 57 it('Should generate token', async function () {
38 await server.videoToken.create({ videoId }) 58 await server.videoToken.create({ videoId: privateVideoId })
59 })
60
61 it('Should generate token on password protected video', async function () {
62 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null })
63 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken })
64 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword })
39 }) 65 })
40 66
41 after(async function () { 67 after(async function () {
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index af9d681b2..2a7c3381d 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () {
107 describe('VOD', function () { 107 describe('VOD', function () {
108 let privateVideoUUID: string 108 let privateVideoUUID: string
109 let publicVideoUUID: string 109 let publicVideoUUID: string
110 let passwordProtectedVideoUUID: string
110 let userPrivateVideoUUID: string 111 let userPrivateVideoUUID: string
111 112
113 const correctPassword = 'my super password'
114 const correctPasswordHeader = { 'x-peertube-video-password': correctPassword }
115 const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' }
116
112 // --------------------------------------------------------------------------- 117 // ---------------------------------------------------------------------------
113 118
114 async function getSampleFileUrls (videoId: string) { 119 async function getSampleFileUrls (videoId: string) {
@@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
140 await checkPrivateVODFiles(privateVideoUUID) 145 await checkPrivateVODFiles(privateVideoUUID)
141 }) 146 })
142 147
148 it('Should upload a password protected video and have appropriate object storage ACL', async function () {
149 this.timeout(120000)
150
151 {
152 const { uuid } = await server.videos.quickUpload({
153 name: 'video',
154 privacy: VideoPrivacy.PASSWORD_PROTECTED,
155 videoPasswords: [ correctPassword ]
156 })
157 passwordProtectedVideoUUID = uuid
158 }
159 await waitJobs([ server ])
160
161 await checkPrivateVODFiles(passwordProtectedVideoUUID)
162 })
163
143 it('Should upload a public video and have appropriate object storage ACL', async function () { 164 it('Should upload a public video and have appropriate object storage ACL', async function () {
144 this.timeout(120000) 165 this.timeout(120000)
145 166
@@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () {
163 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 184 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
164 }) 185 })
165 186
187 it('Should not get files without appropriate password or appropriate OAuth token', async function () {
188 this.timeout(60000)
189
190 const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
191
192 await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
193 await makeRawRequest({
194 url: webTorrentFile,
195 token: null,
196 headers: incorrectPasswordHeader,
197 expectedStatus: HttpStatusCode.FORBIDDEN_403
198 })
199 await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
200 await makeRawRequest({
201 url: webTorrentFile,
202 token: null,
203 headers: correctPasswordHeader,
204 expectedStatus: HttpStatusCode.OK_200
205 })
206
207 await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
208 await makeRawRequest({
209 url: hlsFile,
210 token: null,
211 headers: incorrectPasswordHeader,
212 expectedStatus: HttpStatusCode.FORBIDDEN_403
213 })
214 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
215 await makeRawRequest({
216 url: hlsFile,
217 token: null,
218 headers: correctPasswordHeader,
219 expectedStatus: HttpStatusCode.OK_200
220 })
221 })
222
166 it('Should not get HLS file of another video', async function () { 223 it('Should not get HLS file of another video', async function () {
167 this.timeout(60000) 224 this.timeout(60000)
168 225
@@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () {
176 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 233 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
177 }) 234 })
178 235
179 it('Should correctly check OAuth or video file token', async function () { 236 it('Should correctly check OAuth, video file token of private video', async function () {
180 this.timeout(60000) 237 this.timeout(60000)
181 238
182 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) 239 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
@@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () {
191 248
192 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 249 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
193 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) 250 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
251
252 }
253 })
254
255 it('Should correctly check OAuth, video file token or video password of password protected video', async function () {
256 this.timeout(60000)
257
258 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
259 const goodVideoFileToken = await server.videoToken.getVideoFileToken({
260 videoId: passwordProtectedVideoUUID,
261 videoPassword: correctPassword
262 })
263
264 const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
265
266 for (const url of [ hlsFile, webTorrentFile ]) {
267 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
268 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
269 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
270
271 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
272 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
273
274 await makeRawRequest({
275 url,
276 headers: incorrectPasswordHeader,
277 expectedStatus: HttpStatusCode.FORBIDDEN_403
278 })
279 await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 })
194 } 280 }
195 }) 281 })
196 282
@@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () {
232 let permanentLiveId: string 318 let permanentLiveId: string
233 let permanentLive: LiveVideo 319 let permanentLive: LiveVideo
234 320
321 let passwordProtectedLiveId: string
322 let passwordProtectedLive: LiveVideo
323
324 const correctPassword = 'my super password'
325
235 let unrelatedFileToken: string 326 let unrelatedFileToken: string
236 327
237 // --------------------------------------------------------------------------- 328 // ---------------------------------------------------------------------------
238 329
239 async function checkLiveFiles (live: LiveVideo, liveId: string) { 330 async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) {
240 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 331 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
241 await server.live.waitUntilPublished({ videoId: liveId }) 332 await server.live.waitUntilPublished({ videoId: liveId })
242 333
243 const video = await server.videos.getWithToken({ id: liveId }) 334 const video = videoPassword
244 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 335 ? await server.videos.getWithPassword({ id: liveId, password: videoPassword })
336 : await server.videos.getWithToken({ id: liveId })
337
338 const fileToken = videoPassword
339 ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword })
340 : await server.videoToken.getVideoFileToken({ videoId: video.uuid })
245 341
246 const hls = video.streamingPlaylists[0] 342 const hls = video.streamingPlaylists[0]
247 343
@@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () {
253 349
254 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 350 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
255 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) 351 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
256 352 if (videoPassword) {
353 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
354 }
257 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 355 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
258 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 356 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
259 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 357 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
358 if (videoPassword) {
359 await makeRawRequest({
360 url,
361 headers: { 'x-peertube-video-password': 'incorrectPassword' },
362 expectedStatus: HttpStatusCode.FORBIDDEN_403
363 })
364 }
260 } 365 }
261 366
262 await stopFfmpeg(ffmpegCommand) 367 await stopFfmpeg(ffmpegCommand)
@@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
326 permanentLiveId = video.uuid 431 permanentLiveId = video.uuid
327 permanentLive = live 432 permanentLive = live
328 } 433 }
434
435 {
436 const { video, live } = await server.live.quickCreate({
437 saveReplay: false,
438 permanentLive: false,
439 privacy: VideoPrivacy.PASSWORD_PROTECTED,
440 videoPasswords: [ correctPassword ]
441 })
442 passwordProtectedLiveId = video.uuid
443 passwordProtectedLive = live
444 }
329 }) 445 })
330 446
331 it('Should create a private normal live and have a private static path', async function () { 447 it('Should create a private normal live and have a private static path', async function () {
@@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () {
340 await checkLiveFiles(permanentLive, permanentLiveId) 456 await checkLiveFiles(permanentLive, permanentLiveId)
341 }) 457 })
342 458
459 it('Should create a password protected live and have a private static path', async function () {
460 this.timeout(240000)
461
462 await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword)
463 })
464
343 it('Should reinject video file token in permanent live', async function () { 465 it('Should reinject video file token in permanent live', async function () {
344 this.timeout(240000) 466 this.timeout(240000)
345 467
diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts
new file mode 100644
index 000000000..e01a93a4d
--- /dev/null
+++ b/server/tests/api/videos/video-passwords.ts
@@ -0,0 +1,97 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import {
5 cleanupTests,
6 createSingleServer,
7 VideoPasswordsCommand,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 setDefaultAccountAvatar,
11 setDefaultChannelAvatar
12} from '@shared/server-commands'
13import { VideoPrivacy } from '@shared/models'
14
15describe('Test video passwords', function () {
16 let server: PeerTubeServer
17 let videoUUID: string
18
19 let userAccessTokenServer1: string
20
21 let videoPasswords: string[] = []
22 let command: VideoPasswordsCommand
23
24 before(async function () {
25 this.timeout(30000)
26
27 server = await createSingleServer(1)
28
29 await setAccessTokensToServers([ server ])
30
31 for (let i = 0; i < 10; i++) {
32 videoPasswords.push(`password ${i + 1}`)
33 }
34 const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } })
35 videoUUID = uuid
36
37 await setDefaultChannelAvatar(server)
38 await setDefaultAccountAvatar(server)
39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
43
44 command = server.videoPasswords
45 })
46
47 it('Should list video passwords', async function () {
48 const body = await command.list({ videoId: videoUUID })
49
50 expect(body.total).to.equal(10)
51 expect(body.data).to.be.an('array')
52 expect(body.data).to.have.lengthOf(10)
53 })
54
55 it('Should filter passwords on this video', async function () {
56 const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' })
57
58 expect(body.total).to.equal(10)
59 expect(body.data).to.be.an('array')
60 expect(body.data).to.have.lengthOf(2)
61 expect(body.data[0].password).to.equal('password 4')
62 expect(body.data[1].password).to.equal('password 5')
63 })
64
65 it('Should update password for this video', async function () {
66 videoPasswords = [ 'my super new password 1', 'my super new password 2' ]
67
68 await command.updateAll({ videoId: videoUUID, passwords: videoPasswords })
69 const body = await command.list({ videoId: videoUUID })
70 expect(body.total).to.equal(2)
71 expect(body.data).to.be.an('array')
72 expect(body.data).to.have.lengthOf(2)
73 expect(body.data[0].password).to.equal('my super new password 2')
74 expect(body.data[1].password).to.equal('my super new password 1')
75 })
76
77 it('Should delete one password', async function () {
78 {
79 const body = await command.list({ videoId: videoUUID })
80 expect(body.total).to.equal(2)
81 expect(body.data).to.be.an('array')
82 expect(body.data).to.have.lengthOf(2)
83 await command.remove({ id: body.data[0].id, videoId: videoUUID })
84 }
85 {
86 const body = await command.list({ videoId: videoUUID })
87
88 expect(body.total).to.equal(1)
89 expect(body.data).to.be.an('array')
90 expect(body.data).to.have.lengthOf(1)
91 }
92 })
93
94 after(async function () {
95 await cleanupTests([ server ])
96 })
97})
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index d9c5bdf16..9277b49f4 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -474,7 +474,7 @@ describe('Test video playlists', function () {
474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) 474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
475 }) 475 })
476 476
477 it('Should get unlisted plyaylist using uuid or shortUUID', async function () { 477 it('Should get unlisted playlist using uuid or shortUUID', async function () {
478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) 478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) 479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
480 }) 480 })
@@ -686,7 +686,7 @@ describe('Test video playlists', function () {
686 await waitJobs(servers) 686 await waitJobs(servers)
687 }) 687 })
688 688
689 it('Should update the element type if the video is private', async function () { 689 it('Should update the element type if the video is private/password protected', async function () {
690 this.timeout(20000) 690 this.timeout(20000)
691 691
692 const name = 'video 89' 692 const name = 'video 89'
@@ -703,6 +703,19 @@ describe('Test video playlists', function () {
703 } 703 }
704 704
705 { 705 {
706 await servers[0].videos.update({
707 id: video1,
708 attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
709 })
710 await waitJobs(servers)
711
712 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
713 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
714 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
715 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
716 }
717
718 {
706 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) 719 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
707 await waitJobs(servers) 720 await waitJobs(servers)
708 721
diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts
index 542848533..ec4c697db 100644
--- a/server/tests/api/videos/video-static-file-privacy.ts
+++ b/server/tests/api/videos/video-static-file-privacy.ts
@@ -90,7 +90,7 @@ describe('Test video static file privacy', function () {
90 } 90 }
91 } 91 }
92 92
93 it('Should upload a private/internal video and have a private static path', async function () { 93 it('Should upload a private/internal/password protected video and have a private static path', async function () {
94 this.timeout(120000) 94 this.timeout(120000)
95 95
96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { 96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
@@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
99 99
100 await checkPrivateFiles(uuid) 100 await checkPrivateFiles(uuid)
101 } 101 }
102
103 const { uuid } = await server.videos.quickUpload({
104 name: 'video',
105 privacy: VideoPrivacy.PASSWORD_PROTECTED,
106 videoPasswords: [ 'my super password' ]
107 })
108 await waitJobs([ server ])
109
110 await checkPrivateFiles(uuid)
102 }) 111 })
103 112
104 it('Should upload a public video and update it as private/internal to have a private static path', async function () { 113 it('Should upload a public video and update it as private/internal to have a private static path', async function () {
@@ -185,8 +194,9 @@ describe('Test video static file privacy', function () {
185 expectedStatus: HttpStatusCode 194 expectedStatus: HttpStatusCode
186 token: string 195 token: string
187 videoFileToken: string 196 videoFileToken: string
197 videoPassword?: string
188 }) { 198 }) {
189 const { id, expectedStatus, token, videoFileToken } = options 199 const { id, expectedStatus, token, videoFileToken, videoPassword } = options
190 200
191 const video = await server.videos.getWithToken({ id }) 201 const video = await server.videos.getWithToken({ id })
192 202
@@ -196,6 +206,12 @@ describe('Test video static file privacy', function () {
196 206
197 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) 207 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
198 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) 208 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
209
210 if (videoPassword) {
211 const headers = { 'x-peertube-video-password': videoPassword }
212 await makeRawRequest({ url: file.fileUrl, headers, expectedStatus })
213 await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus })
214 }
199 } 215 }
200 216
201 const hls = video.streamingPlaylists[0] 217 const hls = video.streamingPlaylists[0]
@@ -204,6 +220,12 @@ describe('Test video static file privacy', function () {
204 220
205 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) 221 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
206 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) 222 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
223
224 if (videoPassword) {
225 const headers = { 'x-peertube-video-password': videoPassword }
226 await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus })
227 await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus })
228 }
207 } 229 }
208 230
209 before(async function () { 231 before(async function () {
@@ -216,13 +238,53 @@ describe('Test video static file privacy', function () {
216 it('Should not be able to access a private video files without OAuth token and file token', async function () { 238 it('Should not be able to access a private video files without OAuth token and file token', async function () {
217 this.timeout(120000) 239 this.timeout(120000)
218 240
219 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) 241 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
220 await waitJobs([ server ]) 242 await waitJobs([ server ])
221 243
222 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) 244 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null })
223 }) 245 })
224 246
225 it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { 247 it('Should not be able to access password protected video files without OAuth token, file token and password', async function () {
248 this.timeout(120000)
249 const videoPassword = 'my super password'
250
251 const { uuid } = await server.videos.quickUpload({
252 name: 'password protected video',
253 privacy: VideoPrivacy.PASSWORD_PROTECTED,
254 videoPasswords: [ videoPassword ]
255 })
256 await waitJobs([ server ])
257
258 await checkVideoFiles({
259 id: uuid,
260 expectedStatus: HttpStatusCode.FORBIDDEN_403,
261 token: null,
262 videoFileToken: null,
263 videoPassword: null
264 })
265 })
266
267 it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () {
268 this.timeout(120000)
269 const videoPassword = 'my super password'
270
271 const { uuid } = await server.videos.quickUpload({
272 name: 'password protected video',
273 privacy: VideoPrivacy.PASSWORD_PROTECTED,
274 videoPasswords: [ videoPassword ]
275 })
276 await waitJobs([ server ])
277
278 await checkVideoFiles({
279 id: uuid,
280 expectedStatus: HttpStatusCode.FORBIDDEN_403,
281 token: userToken,
282 videoFileToken: unrelatedFileToken,
283 videoPassword: 'incorrectPassword'
284 })
285 })
286
287 it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () {
226 this.timeout(120000) 288 this.timeout(120000)
227 289
228 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 290 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -247,6 +309,23 @@ describe('Test video static file privacy', function () {
247 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) 309 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
248 }) 310 })
249 311
312 it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () {
313 this.timeout(120000)
314 const videoPassword = 'my super password'
315
316 const { uuid } = await server.videos.quickUpload({
317 name: 'video',
318 privacy: VideoPrivacy.PASSWORD_PROTECTED,
319 videoPasswords: [ videoPassword ]
320 })
321
322 const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword })
323
324 await waitJobs([ server ])
325
326 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword })
327 })
328
250 it('Should reinject video file token', async function () { 329 it('Should reinject video file token', async function () {
251 this.timeout(120000) 330 this.timeout(120000)
252 331
@@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
294 let permanentLiveId: string 373 let permanentLiveId: string
295 let permanentLive: LiveVideo 374 let permanentLive: LiveVideo
296 375
376 let passwordProtectedLiveId: string
377 let passwordProtectedLive: LiveVideo
378
379 const correctPassword = 'my super password'
380
297 let unrelatedFileToken: string 381 let unrelatedFileToken: string
298 382
299 async function checkLiveFiles (live: LiveVideo, liveId: string) { 383 async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) {
384 const { live, liveId, videoPassword } = options
300 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 385 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
301 await server.live.waitUntilPublished({ videoId: liveId }) 386 await server.live.waitUntilPublished({ videoId: liveId })
302 387
303 const video = await server.videos.getWithToken({ id: liveId }) 388 const video = await server.videos.getWithToken({ id: liveId })
389
304 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 390 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
305 391
306 const hls = video.streamingPlaylists[0] 392 const hls = video.streamingPlaylists[0]
@@ -314,6 +400,16 @@ describe('Test video static file privacy', function () {
314 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 400 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
315 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 401 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
316 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 402 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
403
404 if (videoPassword) {
405 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
406 await makeRawRequest({
407 url,
408 headers: { 'x-peertube-video-password': 'incorrectPassword' },
409 expectedStatus: HttpStatusCode.FORBIDDEN_403
410 })
411 }
412
317 } 413 }
318 414
319 await stopFfmpeg(ffmpegCommand) 415 await stopFfmpeg(ffmpegCommand)
@@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
381 permanentLiveId = video.uuid 477 permanentLiveId = video.uuid
382 permanentLive = live 478 permanentLive = live
383 } 479 }
480
481 {
482 const { video, live } = await server.live.quickCreate({
483 saveReplay: false,
484 permanentLive: false,
485 privacy: VideoPrivacy.PASSWORD_PROTECTED,
486 videoPasswords: [ correctPassword ]
487 })
488 passwordProtectedLiveId = video.uuid
489 passwordProtectedLive = live
490 }
384 }) 491 })
385 492
386 it('Should create a private normal live and have a private static path', async function () { 493 it('Should create a private normal live and have a private static path', async function () {
387 this.timeout(240000) 494 this.timeout(240000)
388 495
389 await checkLiveFiles(normalLive, normalLiveId) 496 await checkLiveFiles({ live: normalLive, liveId: normalLiveId })
390 }) 497 })
391 498
392 it('Should create a private permanent live and have a private static path', async function () { 499 it('Should create a private permanent live and have a private static path', async function () {
393 this.timeout(240000) 500 this.timeout(240000)
394 501
395 await checkLiveFiles(permanentLive, permanentLiveId) 502 await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId })
503 })
504
505 it('Should create a password protected live and have a private static path', async function () {
506 this.timeout(240000)
507
508 await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword })
396 }) 509 })
397 510
398 it('Should reinject video file token on permanent live', async function () { 511 it('Should reinject video file token on permanent live', async function () {
diff --git a/server/tests/client.ts b/server/tests/client.ts
index e84251561..68f3a1d14 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -56,6 +56,7 @@ describe('Test a client controllers', function () {
56 let privateVideoId: string 56 let privateVideoId: string
57 let internalVideoId: string 57 let internalVideoId: string
58 let unlistedVideoId: string 58 let unlistedVideoId: string
59 let passwordProtectedVideoId: string
59 60
60 let playlistIds: (string | number)[] = [] 61 let playlistIds: (string | number)[] = []
61 62
@@ -92,7 +93,12 @@ describe('Test a client controllers', function () {
92 { 93 {
93 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); 94 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
94 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); 95 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
95 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })) 96 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
97 ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
98 name: 'password protected',
99 privacy: VideoPrivacy.PASSWORD_PROTECTED,
100 videoPasswords: [ 'password' ]
101 }))
96 } 102 }
97 103
98 // Playlist 104 // Playlist
@@ -502,9 +508,9 @@ describe('Test a client controllers', function () {
502 } 508 }
503 }) 509 })
504 510
505 it('Should not display internal/private video', async function () { 511 it('Should not display internal/private/password protected video', async function () {
506 for (const basePath of watchVideoBasePaths) { 512 for (const basePath of watchVideoBasePaths) {
507 for (const id of [ privateVideoId, internalVideoId ]) { 513 for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
508 const res = await makeGetRequest({ 514 const res = await makeGetRequest({
509 url: servers[0].url, 515 url: servers[0].url,
510 path: basePath + id, 516 path: basePath + id,
@@ -514,6 +520,7 @@ describe('Test a client controllers', function () {
514 520
515 expect(res.text).to.not.contain('internal') 521 expect(res.text).to.not.contain('internal')
516 expect(res.text).to.not.contain('private') 522 expect(res.text).to.not.contain('private')
523 expect(res.text).to.not.contain('password protected')
517 } 524 }
518 } 525 }
519 }) 526 })
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 8433c873e..83a85be58 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -99,6 +99,13 @@ describe('Test syndication feeds', () => {
99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) 99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
100 } 100 }
101 101
102 {
103 const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
104 const { id } = await servers[0].videos.upload({ attributes })
105
106 await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
107 }
108
102 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) 109 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
103 110
104 await waitJobs([ ...servers, serverHLSOnly ]) 111 await waitJobs([ ...servers, serverHLSOnly ])
@@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
445 452
446 describe('Video comments feed', function () { 453 describe('Video comments feed', function () {
447 454
448 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { 455 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
449 for (const server of servers) { 456 for (const server of servers) {
450 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) 457 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
451 458
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 510b9f94e..9c1be9bd1 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -18,6 +18,7 @@ import {
18 MVideoId, 18 MVideoId,
19 MVideoImmutable, 19 MVideoImmutable,
20 MVideoLiveFormattable, 20 MVideoLiveFormattable,
21 MVideoPassword,
21 MVideoPlaylistFull, 22 MVideoPlaylistFull,
22 MVideoPlaylistFullSummary 23 MVideoPlaylistFullSummary
23} from '@server/types/models' 24} from '@server/types/models'
@@ -165,6 +166,8 @@ declare module 'express' {
165 videoCommentFull?: MCommentOwnerVideoReply 166 videoCommentFull?: MCommentOwnerVideoReply
166 videoCommentThread?: MComment 167 videoCommentThread?: MComment
167 168
169 videoPassword?: MVideoPassword
170
168 follow?: MActorFollowActorsDefault 171 follow?: MActorFollowActorsDefault
169 subscription?: MActorFollowActorsDefaultSubscription 172 subscription?: MActorFollowActorsDefaultSubscription
170 173
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 6e45fcc79..0ac032290 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -16,6 +16,7 @@ export * from './video-import'
16export * from './video-live-replay-setting' 16export * from './video-live-replay-setting'
17export * from './video-live-session' 17export * from './video-live-session'
18export * from './video-live' 18export * from './video-live'
19export * from './video-password'
19export * from './video-playlist' 20export * from './video-playlist'
20export * from './video-playlist-element' 21export * from './video-playlist-element'
21export * from './video-rate' 22export * from './video-rate'
diff --git a/server/types/models/video/video-password.ts b/server/types/models/video/video-password.ts
new file mode 100644
index 000000000..313cc3e0c
--- /dev/null
+++ b/server/types/models/video/video-password.ts
@@ -0,0 +1,3 @@
1import { VideoPasswordModel } from '@server/models/video/video-password'
2
3export type MVideoPassword = Omit<VideoPasswordModel, 'Video'>
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts
index 58ae7baad..8021e56bb 100644
--- a/server/types/models/video/video.ts
+++ b/server/types/models/video/video.ts
@@ -32,7 +32,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
32export type MVideo = 32export type MVideo =
33 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | 33 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
34 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | 34 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
35 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers'> 35 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'>
36 36
37// ############################################################################ 37// ############################################################################
38 38
@@ -46,7 +46,7 @@ export type MVideoFeed = Pick<MVideo, 'name' | 'uuid'>
46 46
47// ############################################################################ 47// ############################################################################
48 48
49// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists 49// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords
50 50
51// "With" to not confuse with the VideoFile model 51// "With" to not confuse with the VideoFile model
52export type MVideoWithFile = 52export type MVideoWithFile =
diff --git a/shared/core-utils/videos/common.ts b/shared/core-utils/videos/common.ts
index 2c6efdb7f..0431edaaf 100644
--- a/shared/core-utils/videos/common.ts
+++ b/shared/core-utils/videos/common.ts
@@ -3,7 +3,7 @@ import { VideoPrivacy } from '../../models/videos/video-privacy.enum'
3import { VideoDetails } from '../../models/videos/video.model' 3import { VideoDetails } from '../../models/videos/video.model'
4 4
5function getAllPrivacies () { 5function getAllPrivacies () {
6 return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ] 6 return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ]
7} 7}
8 8
9function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { 9function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) {
diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts
index 2b093380c..77d1e1d3f 100644
--- a/shared/models/server/server-error-code.enum.ts
+++ b/shared/models/server/server-error-code.enum.ts
@@ -49,7 +49,10 @@ export const enum ServerErrorCode {
49 49
50 RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', 50 RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state',
51 RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state', 51 RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state',
52 UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token' 52 UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token',
53
54 VIDEO_REQUIRES_PASSWORD = 'video_requires_password',
55 INCORRECT_VIDEO_PASSWORD = 'incorrect_video_password'
53} 56}
54 57
55/** 58/**
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 4c1790228..80be1854b 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -39,3 +39,4 @@ export * from './video-update.model'
39export * from './video-view.model' 39export * from './video-view.model'
40export * from './video.model' 40export * from './video.model'
41export * from './video-create-result.model' 41export * from './video-create-result.model'
42export * from './video-password.model'
diff --git a/shared/models/videos/video-create.model.ts b/shared/models/videos/video-create.model.ts
index 732d508d1..7a34b5afe 100644
--- a/shared/models/videos/video-create.model.ts
+++ b/shared/models/videos/video-create.model.ts
@@ -18,6 +18,7 @@ export interface VideoCreate {
18 privacy: VideoPrivacy 18 privacy: VideoPrivacy
19 scheduleUpdate?: VideoScheduleUpdate 19 scheduleUpdate?: VideoScheduleUpdate
20 originallyPublishedAt?: Date | string 20 originallyPublishedAt?: Date | string
21 videoPasswords?: string[]
21 22
22 thumbnailfile?: Blob | string 23 thumbnailfile?: Blob | string
23 previewfile?: Blob | string 24 previewfile?: Blob | string
diff --git a/shared/models/videos/video-password.model.ts b/shared/models/videos/video-password.model.ts
new file mode 100644
index 000000000..c0280b9b9
--- /dev/null
+++ b/shared/models/videos/video-password.model.ts
@@ -0,0 +1,7 @@
1export interface VideoPassword {
2 id: number
3 password: string
4 videoId: number
5 createdAt: Date | string
6 updatedAt: Date | string
7}
diff --git a/shared/models/videos/video-privacy.enum.ts b/shared/models/videos/video-privacy.enum.ts
index 39fd0529f..12e1d196f 100644
--- a/shared/models/videos/video-privacy.enum.ts
+++ b/shared/models/videos/video-privacy.enum.ts
@@ -2,5 +2,6 @@ export const enum VideoPrivacy {
2 PUBLIC = 1, 2 PUBLIC = 1,
3 UNLISTED = 2, 3 UNLISTED = 2,
4 PRIVATE = 3, 4 PRIVATE = 3,
5 INTERNAL = 4 5 INTERNAL = 4,
6 PASSWORD_PROTECTED = 5
6} 7}
diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts
index 86653b959..43537b5af 100644
--- a/shared/models/videos/video-update.model.ts
+++ b/shared/models/videos/video-update.model.ts
@@ -19,6 +19,7 @@ export interface VideoUpdate {
19 previewfile?: Blob 19 previewfile?: Blob
20 scheduleUpdate?: VideoScheduleUpdate 20 scheduleUpdate?: VideoScheduleUpdate
21 originallyPublishedAt?: Date | string 21 originallyPublishedAt?: Date | string
22 videoPasswords?: string[]
22 23
23 pluginData?: any 24 pluginData?: any
24} 25}
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index e3f1817f1..8227017eb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -29,6 +29,7 @@ function makeRawRequest (options: {
29 range?: string 29 range?: string
30 query?: { [ id: string ]: string } 30 query?: { [ id: string ]: string }
31 method?: 'GET' | 'POST' 31 method?: 'GET' | 'POST'
32 headers?: { [ name: string ]: string }
32}) { 33}) {
33 const { host, protocol, pathname } = new URL(options.url) 34 const { host, protocol, pathname } = new URL(options.url)
34 35
@@ -37,7 +38,7 @@ function makeRawRequest (options: {
37 path: pathname, 38 path: pathname,
38 contentType: undefined, 39 contentType: undefined,
39 40
40 ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ]) 41 ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ])
41 } 42 }
42 43
43 if (options.method === 'POST') { 44 if (options.method === 'POST') {
@@ -132,6 +133,7 @@ function makePutBodyRequest (options: {
132 token?: string 133 token?: string
133 fields: { [ fieldName: string ]: any } 134 fields: { [ fieldName: string ]: any }
134 expectedStatus?: HttpStatusCode 135 expectedStatus?: HttpStatusCode
136 headers?: { [name: string]: string }
135}) { 137}) {
136 const req = request(options.url).put(options.path) 138 const req = request(options.url).put(options.path)
137 .send(options.fields) 139 .send(options.fields)
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index 70f7a3ee2..0911e22b0 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -32,6 +32,7 @@ import {
32 HistoryCommand, 32 HistoryCommand,
33 ImportsCommand, 33 ImportsCommand,
34 LiveCommand, 34 LiveCommand,
35 VideoPasswordsCommand,
35 PlaylistsCommand, 36 PlaylistsCommand,
36 ServicesCommand, 37 ServicesCommand,
37 StreamingPlaylistsCommand, 38 StreamingPlaylistsCommand,
@@ -146,6 +147,7 @@ export class PeerTubeServer {
146 twoFactor?: TwoFactorCommand 147 twoFactor?: TwoFactorCommand
147 videoToken?: VideoTokenCommand 148 videoToken?: VideoTokenCommand
148 registrations?: RegistrationsCommand 149 registrations?: RegistrationsCommand
150 videoPasswords?: VideoPasswordsCommand
149 151
150 runners?: RunnersCommand 152 runners?: RunnersCommand
151 runnerRegistrationTokens?: RunnerRegistrationTokensCommand 153 runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -437,5 +439,6 @@ export class PeerTubeServer {
437 this.runners = new RunnersCommand(this) 439 this.runners = new RunnersCommand(this)
438 this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) 440 this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
439 this.runnerJobs = new RunnerJobsCommand(this) 441 this.runnerJobs = new RunnerJobsCommand(this)
442 this.videoPasswords = new VideoPasswordsCommand(this)
440 } 443 }
441} 444}
diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts
index ca4ffada9..463acc26b 100644
--- a/shared/server-commands/shared/abstract-command.ts
+++ b/shared/server-commands/shared/abstract-command.ts
@@ -101,25 +101,29 @@ abstract class AbstractCommand {
101 101
102 protected putBodyRequest (options: InternalCommonCommandOptions & { 102 protected putBodyRequest (options: InternalCommonCommandOptions & {
103 fields?: { [ fieldName: string ]: any } 103 fields?: { [ fieldName: string ]: any }
104 headers?: { [name: string]: string }
104 }) { 105 }) {
105 const { fields } = options 106 const { fields, headers } = options
106 107
107 return makePutBodyRequest({ 108 return makePutBodyRequest({
108 ...this.buildCommonRequestOptions(options), 109 ...this.buildCommonRequestOptions(options),
109 110
110 fields 111 fields,
112 headers
111 }) 113 })
112 } 114 }
113 115
114 protected postBodyRequest (options: InternalCommonCommandOptions & { 116 protected postBodyRequest (options: InternalCommonCommandOptions & {
115 fields?: { [ fieldName: string ]: any } 117 fields?: { [ fieldName: string ]: any }
118 headers?: { [name: string]: string }
116 }) { 119 }) {
117 const { fields } = options 120 const { fields, headers } = options
118 121
119 return makePostBodyRequest({ 122 return makePostBodyRequest({
120 ...this.buildCommonRequestOptions(options), 123 ...this.buildCommonRequestOptions(options),
121 124
122 fields 125 fields,
126 headers
123 }) 127 })
124 } 128 }
125 129
@@ -206,6 +210,12 @@ abstract class AbstractCommand {
206 210
207 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus 211 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus
208 } 212 }
213
214 protected buildVideoPasswordHeader (videoPassword: string) {
215 return videoPassword !== undefined && videoPassword !== null
216 ? { 'x-peertube-video-password': videoPassword }
217 : undefined
218 }
209} 219}
210 220
211export { 221export {
diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts
index 62bf9c5e6..a26fcb57d 100644
--- a/shared/server-commands/videos/captions-command.ts
+++ b/shared/server-commands/videos/captions-command.ts
@@ -34,14 +34,16 @@ export class CaptionsCommand extends AbstractCommand {
34 34
35 list (options: OverrideCommandOptions & { 35 list (options: OverrideCommandOptions & {
36 videoId: string | number 36 videoId: string | number
37 videoPassword?: string
37 }) { 38 }) {
38 const { videoId } = options 39 const { videoId, videoPassword } = options
39 const path = '/api/v1/videos/' + videoId + '/captions' 40 const path = '/api/v1/videos/' + videoId + '/captions'
40 41
41 return this.getRequestBody<ResultList<VideoCaption>>({ 42 return this.getRequestBody<ResultList<VideoCaption>>({
42 ...options, 43 ...options,
43 44
44 path, 45 path,
46 headers: this.buildVideoPasswordHeader(videoPassword),
45 implicitToken: false, 47 implicitToken: false,
46 defaultExpectedStatus: HttpStatusCode.OK_200 48 defaultExpectedStatus: HttpStatusCode.OK_200
47 }) 49 })
diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts
index 154ec0c24..0dab1b66a 100644
--- a/shared/server-commands/videos/comments-command.ts
+++ b/shared/server-commands/videos/comments-command.ts
@@ -36,11 +36,12 @@ export class CommentsCommand extends AbstractCommand {
36 36
37 listThreads (options: OverrideCommandOptions & { 37 listThreads (options: OverrideCommandOptions & {
38 videoId: number | string 38 videoId: number | string
39 videoPassword?: string
39 start?: number 40 start?: number
40 count?: number 41 count?: number
41 sort?: string 42 sort?: string
42 }) { 43 }) {
43 const { start, count, sort, videoId } = options 44 const { start, count, sort, videoId, videoPassword } = options
44 const path = '/api/v1/videos/' + videoId + '/comment-threads' 45 const path = '/api/v1/videos/' + videoId + '/comment-threads'
45 46
46 return this.getRequestBody<VideoCommentThreads>({ 47 return this.getRequestBody<VideoCommentThreads>({
@@ -48,6 +49,7 @@ export class CommentsCommand extends AbstractCommand {
48 49
49 path, 50 path,
50 query: { start, count, sort }, 51 query: { start, count, sort },
52 headers: this.buildVideoPasswordHeader(videoPassword),
51 implicitToken: false, 53 implicitToken: false,
52 defaultExpectedStatus: HttpStatusCode.OK_200 54 defaultExpectedStatus: HttpStatusCode.OK_200
53 }) 55 })
@@ -72,8 +74,9 @@ export class CommentsCommand extends AbstractCommand {
72 async createThread (options: OverrideCommandOptions & { 74 async createThread (options: OverrideCommandOptions & {
73 videoId: number | string 75 videoId: number | string
74 text: string 76 text: string
77 videoPassword?: string
75 }) { 78 }) {
76 const { videoId, text } = options 79 const { videoId, text, videoPassword } = options
77 const path = '/api/v1/videos/' + videoId + '/comment-threads' 80 const path = '/api/v1/videos/' + videoId + '/comment-threads'
78 81
79 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ 82 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -81,6 +84,7 @@ export class CommentsCommand extends AbstractCommand {
81 84
82 path, 85 path,
83 fields: { text }, 86 fields: { text },
87 headers: this.buildVideoPasswordHeader(videoPassword),
84 implicitToken: true, 88 implicitToken: true,
85 defaultExpectedStatus: HttpStatusCode.OK_200 89 defaultExpectedStatus: HttpStatusCode.OK_200
86 })) 90 }))
@@ -95,8 +99,9 @@ export class CommentsCommand extends AbstractCommand {
95 videoId: number | string 99 videoId: number | string
96 toCommentId: number 100 toCommentId: number
97 text: string 101 text: string
102 videoPassword?: string
98 }) { 103 }) {
99 const { videoId, toCommentId, text } = options 104 const { videoId, toCommentId, text, videoPassword } = options
100 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId 105 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId
101 106
102 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ 107 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -104,6 +109,7 @@ export class CommentsCommand extends AbstractCommand {
104 109
105 path, 110 path,
106 fields: { text }, 111 fields: { text },
112 headers: this.buildVideoPasswordHeader(videoPassword),
107 implicitToken: true, 113 implicitToken: true,
108 defaultExpectedStatus: HttpStatusCode.OK_200 114 defaultExpectedStatus: HttpStatusCode.OK_200
109 })) 115 }))
diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts
index c17f6ef20..da36b5b6b 100644
--- a/shared/server-commands/videos/index.ts
+++ b/shared/server-commands/videos/index.ts
@@ -17,3 +17,4 @@ export * from './video-studio-command'
17export * from './video-token-command' 17export * from './video-token-command'
18export * from './views-command' 18export * from './views-command'
19export * from './videos-command' 19export * from './videos-command'
20export * from './video-passwords-command'
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
index 44d625970..6006d9fe9 100644
--- a/shared/server-commands/videos/live-command.ts
+++ b/shared/server-commands/videos/live-command.ts
@@ -120,8 +120,13 @@ export class LiveCommand extends AbstractCommand {
120 saveReplay: boolean 120 saveReplay: boolean
121 permanentLive: boolean 121 permanentLive: boolean
122 privacy?: VideoPrivacy 122 privacy?: VideoPrivacy
123 videoPasswords?: string[]
123 }) { 124 }) {
124 const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC } = options 125 const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options
126
127 const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED
128 ? { privacy: VideoPrivacy.PRIVATE }
129 : { privacy }
125 130
126 const { uuid } = await this.create({ 131 const { uuid } = await this.create({
127 ...options, 132 ...options,
@@ -130,9 +135,10 @@ export class LiveCommand extends AbstractCommand {
130 name: 'live', 135 name: 'live',
131 permanentLive, 136 permanentLive,
132 saveReplay, 137 saveReplay,
133 replaySettings: { privacy }, 138 replaySettings,
134 channelId: this.server.store.channel.id, 139 channelId: this.server.store.channel.id,
135 privacy 140 privacy,
141 videoPasswords
136 } 142 }
137 }) 143 })
138 144
diff --git a/shared/server-commands/videos/video-passwords-command.ts b/shared/server-commands/videos/video-passwords-command.ts
new file mode 100644
index 000000000..bf10335b4
--- /dev/null
+++ b/shared/server-commands/videos/video-passwords-command.ts
@@ -0,0 +1,55 @@
1import { HttpStatusCode, ResultList, VideoPassword } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3export class VideoPasswordsCommand extends AbstractCommand {
4
5 list (options: OverrideCommandOptions & {
6 videoId: number | string
7 start?: number
8 count?: number
9 sort?: string
10 }) {
11 const { start, count, sort, videoId } = options
12 const path = '/api/v1/videos/' + videoId + '/passwords'
13
14 return this.getRequestBody<ResultList<VideoPassword>>({
15 ...options,
16
17 path,
18 query: { start, count, sort },
19 implicitToken: true,
20 defaultExpectedStatus: HttpStatusCode.OK_200
21 })
22 }
23
24 updateAll (options: OverrideCommandOptions & {
25 videoId: number | string
26 passwords: string[]
27 }) {
28 const { videoId, passwords } = options
29 const path = `/api/v1/videos/${videoId}/passwords`
30
31 return this.putBodyRequest({
32 ...options,
33 path,
34 fields: { passwords },
35 implicitToken: true,
36 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
37 })
38 }
39
40 remove (options: OverrideCommandOptions & {
41 id: number
42 videoId: number | string
43 }) {
44 const { id, videoId } = options
45 const path = `/api/v1/videos/${videoId}/passwords/${id}`
46
47 return this.deleteRequest({
48 ...options,
49
50 path,
51 implicitToken: true,
52 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
53 })
54 }
55}
diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts
index 0531bee65..c4ed29a8c 100644
--- a/shared/server-commands/videos/video-token-command.ts
+++ b/shared/server-commands/videos/video-token-command.ts
@@ -8,12 +8,14 @@ export class VideoTokenCommand extends AbstractCommand {
8 8
9 create (options: OverrideCommandOptions & { 9 create (options: OverrideCommandOptions & {
10 videoId: number | string 10 videoId: number | string
11 videoPassword?: string
11 }) { 12 }) {
12 const { videoId } = options 13 const { videoId, videoPassword } = options
13 const path = '/api/v1/videos/' + videoId + '/token' 14 const path = '/api/v1/videos/' + videoId + '/token'
14 15
15 return unwrapBody<VideoToken>(this.postBodyRequest({ 16 return unwrapBody<VideoToken>(this.postBodyRequest({
16 ...options, 17 ...options,
18 headers: this.buildVideoPasswordHeader(videoPassword),
17 19
18 path, 20 path,
19 implicitToken: true, 21 implicitToken: true,
@@ -23,6 +25,7 @@ export class VideoTokenCommand extends AbstractCommand {
23 25
24 async getVideoFileToken (options: OverrideCommandOptions & { 26 async getVideoFileToken (options: OverrideCommandOptions & {
25 videoId: number | string 27 videoId: number | string
28 videoPassword?: string
26 }) { 29 }) {
27 const { files } = await this.create(options) 30 const { files } = await this.create(options)
28 31
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index b5df9c325..93ca623e1 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -111,8 +111,9 @@ export class VideosCommand extends AbstractCommand {
111 rate (options: OverrideCommandOptions & { 111 rate (options: OverrideCommandOptions & {
112 id: number | string 112 id: number | string
113 rating: UserVideoRateType 113 rating: UserVideoRateType
114 videoPassword?: string
114 }) { 115 }) {
115 const { id, rating } = options 116 const { id, rating, videoPassword } = options
116 const path = '/api/v1/videos/' + id + '/rate' 117 const path = '/api/v1/videos/' + id + '/rate'
117 118
118 return this.putBodyRequest({ 119 return this.putBodyRequest({
@@ -120,6 +121,7 @@ export class VideosCommand extends AbstractCommand {
120 121
121 path, 122 path,
122 fields: { rating }, 123 fields: { rating },
124 headers: this.buildVideoPasswordHeader(videoPassword),
123 implicitToken: true, 125 implicitToken: true,
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 126 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
125 }) 127 })
@@ -151,6 +153,23 @@ export class VideosCommand extends AbstractCommand {
151 }) 153 })
152 } 154 }
153 155
156 getWithPassword (options: OverrideCommandOptions & {
157 id: number | string
158 password?: string
159 }) {
160 const path = '/api/v1/videos/' + options.id
161
162 return this.getRequestBody<VideoDetails>({
163 ...options,
164 headers:{
165 'x-peertube-video-password': options.password
166 },
167 path,
168 implicitToken: false,
169 defaultExpectedStatus: HttpStatusCode.OK_200
170 })
171 }
172
154 getSource (options: OverrideCommandOptions & { 173 getSource (options: OverrideCommandOptions & {
155 id: number | string 174 id: number | string
156 }) { 175 }) {
@@ -608,11 +627,13 @@ export class VideosCommand extends AbstractCommand {
608 nsfw?: boolean 627 nsfw?: boolean
609 privacy?: VideoPrivacy 628 privacy?: VideoPrivacy
610 fixture?: string 629 fixture?: string
630 videoPasswords?: string[]
611 }) { 631 }) {
612 const attributes: VideoEdit = { name: options.name } 632 const attributes: VideoEdit = { name: options.name }
613 if (options.nsfw) attributes.nsfw = options.nsfw 633 if (options.nsfw) attributes.nsfw = options.nsfw
614 if (options.privacy) attributes.privacy = options.privacy 634 if (options.privacy) attributes.privacy = options.privacy
615 if (options.fixture) attributes.fixture = options.fixture 635 if (options.fixture) attributes.fixture = options.fixture
636 if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords
616 637
617 return this.upload({ ...options, attributes }) 638 return this.upload({ ...options, attributes })
618 } 639 }
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index cd50e86a6..ff94f802b 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -300,6 +300,8 @@ tags:
300 - name: Runner Registration Token 300 - name: Runner Registration Token
301 description: | 301 description: |
302 Manage runner registration token 302 Manage runner registration token
303 - name: Video Passwords
304 description: Operation on video passwords
303 305
304x-tagGroups: 306x-tagGroups:
305 - name: Static endpoints 307 - name: Static endpoints
@@ -337,6 +339,7 @@ x-tagGroups:
337 - Video Transcoding 339 - Video Transcoding
338 - Live Videos 340 - Live Videos
339 - Channels Sync 341 - Channels Sync
342 - Video Passwords
340 - name: Search 343 - name: Search
341 tags: 344 tags:
342 - Search 345 - Search
@@ -2359,6 +2362,7 @@ paths:
2359 - OAuth2: [] 2362 - OAuth2: []
2360 parameters: 2363 parameters:
2361 - $ref: '#/components/parameters/idOrUUID' 2364 - $ref: '#/components/parameters/idOrUUID'
2365 - $ref: '#/components/parameters/videoPasswordHeader'
2362 responses: 2366 responses:
2363 '200': 2367 '200':
2364 description: successful operation 2368 description: successful operation
@@ -2578,6 +2582,8 @@ paths:
2578 format: date-time 2582 format: date-time
2579 scheduleUpdate: 2583 scheduleUpdate:
2580 $ref: '#/components/schemas/VideoScheduledUpdate' 2584 $ref: '#/components/schemas/VideoScheduledUpdate'
2585 videoPasswords:
2586 $ref: '#/components/schemas/AddVideoPasswords'
2581 encoding: 2587 encoding:
2582 thumbnailfile: 2588 thumbnailfile:
2583 contentType: image/jpeg 2589 contentType: image/jpeg
@@ -2590,6 +2596,7 @@ paths:
2590 - Video 2596 - Video
2591 parameters: 2597 parameters:
2592 - $ref: '#/components/parameters/idOrUUID' 2598 - $ref: '#/components/parameters/idOrUUID'
2599 - $ref: '#/components/parameters/videoPasswordHeader'
2593 responses: 2600 responses:
2594 '200': 2601 '200':
2595 description: successful operation 2602 description: successful operation
@@ -2597,6 +2604,8 @@ paths:
2597 application/json: 2604 application/json:
2598 schema: 2605 schema:
2599 $ref: '#/components/schemas/VideoDetails' 2606 $ref: '#/components/schemas/VideoDetails'
2607 '403':
2608 description: provide a correct password to access this password protected video
2600 delete: 2609 delete:
2601 summary: Delete a video 2610 summary: Delete a video
2602 operationId: delVideo 2611 operationId: delVideo
@@ -2618,6 +2627,7 @@ paths:
2618 - Video 2627 - Video
2619 parameters: 2628 parameters:
2620 - $ref: '#/components/parameters/idOrUUID' 2629 - $ref: '#/components/parameters/idOrUUID'
2630 - $ref: '#/components/parameters/videoPasswordHeader'
2621 responses: 2631 responses:
2622 '200': 2632 '200':
2623 description: successful operation 2633 description: successful operation
@@ -3267,6 +3277,7 @@ paths:
3267 - Live Videos 3277 - Live Videos
3268 parameters: 3278 parameters:
3269 - $ref: '#/components/parameters/idOrUUID' 3279 - $ref: '#/components/parameters/idOrUUID'
3280 - $ref: '#/components/parameters/videoPasswordHeader'
3270 responses: 3281 responses:
3271 '200': 3282 '200':
3272 description: successful operation 3283 description: successful operation
@@ -3665,6 +3676,7 @@ paths:
3665 - Video Captions 3676 - Video Captions
3666 parameters: 3677 parameters:
3667 - $ref: '#/components/parameters/idOrUUID' 3678 - $ref: '#/components/parameters/idOrUUID'
3679 - $ref: '#/components/parameters/videoPasswordHeader'
3668 responses: 3680 responses:
3669 '200': 3681 '200':
3670 description: successful operation 3682 description: successful operation
@@ -3728,6 +3740,70 @@ paths:
3728 '404': 3740 '404':
3729 description: video or language or caption for that language not found 3741 description: video or language or caption for that language not found
3730 3742
3743 /api/v1/videos/{id}/passwords:
3744 get:
3745 summary: List video passwords
3746 security:
3747 - OAuth2:
3748 - user
3749 tags:
3750 - Video Passwords
3751 parameters:
3752 - $ref: '#/components/parameters/idOrUUID'
3753 - $ref: '#/components/parameters/start'
3754 - $ref: '#/components/parameters/count'
3755 - $ref: '#/components/parameters/sort'
3756 responses:
3757 '204':
3758 description: successful operation
3759 content:
3760 application/json:
3761 schema:
3762 $ref: '#/components/schemas/VideoPasswordList'
3763 '400':
3764 description: video is not password protected
3765 put:
3766 summary: Update video passwords
3767 security:
3768 - OAuth2:
3769 - user
3770 tags:
3771 - Video Passwords
3772 parameters:
3773 - $ref: '#/components/parameters/idOrUUID'
3774 requestBody:
3775 content:
3776 application/json:
3777 schema:
3778 type: object
3779 properties:
3780 passwords:
3781 $ref: '#/components/schemas/AddVideoPasswords'
3782 responses:
3783 '204':
3784 description: successful operation
3785 '400':
3786 description: video is not password protected
3787
3788 /api/v1/videos/{id}/passwords/{videoPasswordId}:
3789 delete:
3790 summary: Delete a video password
3791 security:
3792 - OAuth2:
3793 - user
3794 tags:
3795 - Video Passwords
3796 parameters:
3797 - $ref: '#/components/parameters/idOrUUID'
3798 - $ref: '#/components/parameters/videoPasswordId'
3799 responses:
3800 '204':
3801 description: successful operation
3802 '403':
3803 description: cannot delete the last password of the protected video
3804 '400':
3805 description: video is not password protected
3806
3731 /api/v1/video-channels: 3807 /api/v1/video-channels:
3732 get: 3808 get:
3733 summary: List video channels 3809 summary: List video channels
@@ -4554,6 +4630,7 @@ paths:
4554 - $ref: '#/components/parameters/start' 4630 - $ref: '#/components/parameters/start'
4555 - $ref: '#/components/parameters/count' 4631 - $ref: '#/components/parameters/count'
4556 - $ref: '#/components/parameters/commentsSort' 4632 - $ref: '#/components/parameters/commentsSort'
4633 - $ref: '#/components/parameters/videoPasswordHeader'
4557 responses: 4634 responses:
4558 '200': 4635 '200':
4559 description: successful operation 4636 description: successful operation
@@ -4600,6 +4677,7 @@ paths:
4600 parameters: 4677 parameters:
4601 - $ref: '#/components/parameters/idOrUUID' 4678 - $ref: '#/components/parameters/idOrUUID'
4602 - $ref: '#/components/parameters/threadId' 4679 - $ref: '#/components/parameters/threadId'
4680 - $ref: '#/components/parameters/videoPasswordHeader'
4603 responses: 4681 responses:
4604 '200': 4682 '200':
4605 description: successful operation 4683 description: successful operation
@@ -4618,6 +4696,7 @@ paths:
4618 parameters: 4696 parameters:
4619 - $ref: '#/components/parameters/idOrUUID' 4697 - $ref: '#/components/parameters/idOrUUID'
4620 - $ref: '#/components/parameters/commentId' 4698 - $ref: '#/components/parameters/commentId'
4699 - $ref: '#/components/parameters/videoPasswordHeader'
4621 responses: 4700 responses:
4622 '200': 4701 '200':
4623 description: successful operation 4702 description: successful operation
@@ -4668,6 +4747,7 @@ paths:
4668 - Video Rates 4747 - Video Rates
4669 parameters: 4748 parameters:
4670 - $ref: '#/components/parameters/idOrUUID' 4749 - $ref: '#/components/parameters/idOrUUID'
4750 - $ref: '#/components/parameters/videoPasswordHeader'
4671 requestBody: 4751 requestBody:
4672 content: 4752 content:
4673 application/json: 4753 application/json:
@@ -6525,7 +6605,20 @@ components:
6525 required: true 6605 required: true
6526 schema: 6606 schema:
6527 $ref: '#/components/schemas/UUIDv4' 6607 $ref: '#/components/schemas/UUIDv4'
6528 6608 videoPasswordId:
6609 name: videoPasswordId
6610 in: path
6611 required: true
6612 description: The video password id
6613 schema:
6614 $ref: '#/components/schemas/id'
6615 videoPasswordHeader:
6616 name: x-peertube-video-password
6617 description: Required on password protected video
6618 in: header
6619 required: false
6620 schema:
6621 type: string
6529 securitySchemes: 6622 securitySchemes:
6530 OAuth2: 6623 OAuth2:
6531 description: | 6624 description: |
@@ -8228,6 +8321,8 @@ components:
8228 description: Video preview file 8321 description: Video preview file
8229 type: string 8322 type: string
8230 format: binary 8323 format: binary
8324 videoPasswords:
8325 $ref: '#/components/schemas/AddVideoPasswords'
8231 required: 8326 required:
8232 - channelId 8327 - channelId
8233 - name 8328 - name
@@ -9616,6 +9711,29 @@ components:
9616 privatePayload: 9711 privatePayload:
9617 type: object 9712 type: object
9618 9713
9714 VideoPassword:
9715 properties:
9716 id:
9717 $ref: '#/components/schemas/id'
9718 password:
9719 type: string
9720 minLength: 2
9721 videoId:
9722 $ref: '#/components/schemas/id'
9723 VideoPasswordList:
9724 properties:
9725 total:
9726 type: integer
9727 example: 1
9728 data:
9729 type: array
9730 items:
9731 $ref: '#/components/schemas/VideoPassword'
9732 AddVideoPasswords:
9733 type: array
9734 items:
9735 $ref: "#/components/schemas/VideoPassword/properties/password"
9736 uniqueItems: true
9619 callbacks: 9737 callbacks:
9620 searchIndex: 9738 searchIndex:
9621 'https://search.example.org/api/v1/search/videos': 9739 'https://search.example.org/api/v1/search/videos':