diff options
author | Wicklow <123956049+wickloww@users.noreply.github.com> | 2023-06-29 07:48:55 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-29 09:48:55 +0200 |
commit | 40346ead2b0b7afa475aef057d3673b6c7574b7a (patch) | |
tree | 24ffdc23c3a9d987334842e0d400b5bd44500cf7 /client/src | |
parent | ae22c59f14d0d553f60b281948b6c232c2aca178 (diff) | |
download | PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.gz PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.zst PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.zip |
Feature/password protected videos (#5836)
* Add server endpoints
* Refactoring test suites
* Update server and add openapi documentation
* fix compliation and tests
* upload/import password protected video on client
* add server error code
* Add video password to update resolver
* add custom message when sharing pw protected video
* improve confirm component
* Add new alert in component
* Add ability to watch protected video on client
* Cannot have password protected replay privacy
* Add migration
* Add tests
* update after review
* Update check params tests
* Add live videos test
* Add more filter test
* Update static file privacy test
* Update object storage tests
* Add test on feeds
* Add missing word
* Fix tests
* Fix tests on live videos
* add embed support on password protected videos
* fix style
* Correcting data leaks
* Unable to add password protected privacy on replay
* Updated code based on review comments
* fix validator and command
* Updated code based on review comments
Diffstat (limited to 'client/src')
56 files changed, 572 insertions, 143 deletions
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts index 4b9357fb7..195b265a1 100644 --- a/client/src/app/+admin/overview/videos/video-admin.service.ts +++ b/client/src/app/+admin/overview/videos/video-admin.service.ts | |||
@@ -151,7 +151,7 @@ export class VideoAdminService { | |||
151 | } | 151 | } |
152 | 152 | ||
153 | if (filters.excludePublic) { | 153 | if (filters.excludePublic) { |
154 | privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] | 154 | privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ] |
155 | 155 | ||
156 | filters.excludePublic = undefined | 156 | filters.excludePublic = undefined |
157 | } | 157 | } |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts index 97ffb6013..393c3ad6b 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts | |||
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit { | |||
30 | async disableTwoFactor () { | 30 | async disableTwoFactor () { |
31 | const message = $localize`Are you sure you want to disable two factor authentication of your account?` | 31 | const message = $localize`Are you sure you want to disable two factor authentication of your account?` |
32 | 32 | ||
33 | const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) | 33 | const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` }) |
34 | if (confirmed === false) return | 34 | if (confirmed === false) return |
35 | 35 | ||
36 | this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) | 36 | this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index b607dabe9..97b713874 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -120,7 +120,12 @@ | |||
120 | </div> | 120 | </div> |
121 | </div> | 121 | </div> |
122 | 122 | ||
123 | <div *ngIf="schedulePublicationEnabled" class="form-group"> | 123 | <div *ngIf="passwordProtectionSelected" class="form-group"> |
124 | <label i18n for="videoPassword">Password</label> | ||
125 | <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text> | ||
126 | </div> | ||
127 | |||
128 | <div *ngIf="schedulePublicationSelected" class="form-group"> | ||
124 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> | 129 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> |
125 | <p-calendar | 130 | <p-calendar |
126 | id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" | 131 | id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" |
@@ -287,7 +292,7 @@ | |||
287 | <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> | 292 | <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> |
288 | <label i18n for="replayPrivacy">Privacy of the new replay</label> | 293 | <label i18n for="replayPrivacy">Privacy of the new replay</label> |
289 | <my-select-options | 294 | <my-select-options |
290 | labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" | 295 | labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy" |
291 | ></my-select-options> | 296 | ></my-select-options> |
292 | </div> | 297 | </div> |
293 | 298 | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 8ed54ce6b..5e5df8db7 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts | |||
@@ -14,6 +14,7 @@ import { | |||
14 | VIDEO_LICENCE_VALIDATOR, | 14 | VIDEO_LICENCE_VALIDATOR, |
15 | VIDEO_NAME_VALIDATOR, | 15 | VIDEO_NAME_VALIDATOR, |
16 | VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, | 16 | VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, |
17 | VIDEO_PASSWORD_VALIDATOR, | ||
17 | VIDEO_PRIVACY_VALIDATOR, | 18 | VIDEO_PRIVACY_VALIDATOR, |
18 | VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, | 19 | VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, |
19 | VIDEO_SUPPORT_VALIDATOR, | 20 | VIDEO_SUPPORT_VALIDATOR, |
@@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
79 | // So that it can be accessed in the template | 80 | // So that it can be accessed in the template |
80 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | 81 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY |
81 | 82 | ||
82 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | 83 | videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = [] |
84 | replayPrivacies: VideoConstant<VideoPrivacy> [] = [] | ||
83 | videoCategories: VideoConstant<number>[] = [] | 85 | videoCategories: VideoConstant<number>[] = [] |
84 | videoLicences: VideoConstant<number>[] = [] | 86 | videoLicences: VideoConstant<number>[] = [] |
85 | videoLanguages: VideoLanguages[] = [] | 87 | videoLanguages: VideoLanguages[] = [] |
@@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
103 | 105 | ||
104 | pluginDataFormGroup: FormGroup | 106 | pluginDataFormGroup: FormGroup |
105 | 107 | ||
106 | schedulePublicationEnabled = false | 108 | schedulePublicationSelected = false |
109 | passwordProtectionSelected = false | ||
107 | 110 | ||
108 | calendarLocale: any = {} | 111 | calendarLocale: any = {} |
109 | minScheduledDate = new Date() | 112 | minScheduledDate = new Date() |
@@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
148 | const obj: { [ id: string ]: BuildFormValidator } = { | 151 | const obj: { [ id: string ]: BuildFormValidator } = { |
149 | name: VIDEO_NAME_VALIDATOR, | 152 | name: VIDEO_NAME_VALIDATOR, |
150 | privacy: VIDEO_PRIVACY_VALIDATOR, | 153 | privacy: VIDEO_PRIVACY_VALIDATOR, |
154 | videoPassword: VIDEO_PASSWORD_VALIDATOR, | ||
151 | channelId: VIDEO_CHANNEL_VALIDATOR, | 155 | channelId: VIDEO_CHANNEL_VALIDATOR, |
152 | nsfw: null, | 156 | nsfw: null, |
153 | commentsEnabled: null, | 157 | commentsEnabled: null, |
@@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
222 | 226 | ||
223 | this.serverService.getVideoPrivacies() | 227 | this.serverService.getVideoPrivacies() |
224 | .subscribe(privacies => { | 228 | .subscribe(privacies => { |
225 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies | 229 | const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies |
230 | this.videoPrivacies = videoPrivacies | ||
231 | this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED) | ||
226 | 232 | ||
227 | // Can't schedule publication if private privacy is not available (could be deleted by a plugin) | 233 | // Can't schedule publication if private privacy is not available (could be deleted by a plugin) |
228 | const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) | 234 | const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) |
@@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
410 | .subscribe( | 416 | .subscribe( |
411 | newPrivacyId => { | 417 | newPrivacyId => { |
412 | 418 | ||
413 | this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY | 419 | this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY |
414 | 420 | ||
415 | // Value changed | 421 | // Value changed |
416 | const scheduleControl = this.form.get('schedulePublicationAt') | 422 | const scheduleControl = this.form.get('schedulePublicationAt') |
417 | const waitTranscodingControl = this.form.get('waitTranscoding') | 423 | const waitTranscodingControl = this.form.get('waitTranscoding') |
418 | 424 | ||
419 | if (this.schedulePublicationEnabled) { | 425 | if (this.schedulePublicationSelected) { |
420 | scheduleControl.setValidators([ Validators.required ]) | 426 | scheduleControl.setValidators([ Validators.required ]) |
421 | 427 | ||
422 | waitTranscodingControl.disable() | 428 | waitTranscodingControl.disable() |
@@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
437 | 443 | ||
438 | this.firstPatchDone = true | 444 | this.firstPatchDone = true |
439 | 445 | ||
446 | this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED | ||
447 | const videoPasswordControl = this.form.get('videoPassword') | ||
448 | |||
449 | if (this.passwordProtectionSelected) { | ||
450 | videoPasswordControl.setValidators([ Validators.required ]) | ||
451 | } else { | ||
452 | videoPasswordControl.clearValidators() | ||
453 | } | ||
454 | videoPasswordControl.updateValueAndValidity() | ||
455 | |||
440 | } | 456 | } |
441 | ) | 457 | ) |
442 | } | 458 | } |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index ad71162b8..629d95c08 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
49 | this.buildForm({}) | 49 | this.buildForm({}) |
50 | 50 | ||
51 | const { videoData } = this.route.snapshot.data | 51 | const { videoData } = this.route.snapshot.data |
52 | const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData | 52 | const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData |
53 | 53 | ||
54 | this.videoDetails = video | 54 | this.videoDetails = video |
55 | this.videoEdit = new VideoEdit(this.videoDetails) | 55 | this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) |
56 | 56 | ||
57 | this.userVideoChannels = videoChannels | 57 | this.userVideoChannels = videoChannels |
58 | this.videoCaptions = videoCaptions | 58 | this.videoCaptions = videoCaptions |
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts index 6612d22de..2c99b36a8 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts | |||
@@ -4,8 +4,9 @@ import { Injectable } from '@angular/core' | |||
4 | import { ActivatedRouteSnapshot } from '@angular/router' | 4 | import { ActivatedRouteSnapshot } from '@angular/router' |
5 | import { AuthService } from '@app/core' | 5 | import { AuthService } from '@app/core' |
6 | import { listUserChannelsForSelect } from '@app/helpers' | 6 | import { listUserChannelsForSelect } from '@app/helpers' |
7 | import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { VideoPrivacy } from '@shared/models/videos' | ||
9 | 10 | ||
10 | @Injectable() | 11 | @Injectable() |
11 | export class VideoUpdateResolver { | 12 | export 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' | |||
5 | import { SupportModalComponent } from '@app/shared/shared-support-modal' | 5 | import { SupportModalComponent } from '@app/shared/shared-support-modal' |
6 | import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' | 6 | import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' |
7 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | 7 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' |
8 | import { UserVideoRateType, VideoCaption } from '@shared/models/videos' | 8 | import { 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 | }) |
13 | export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { | 13 | export 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' | |||
29 | export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { | 29 | export 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' | |||
15 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | 15 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { AuthUser } from '@app/core' | ||
2 | import { VideoDetails } from '@app/shared/shared-main' | 3 | import { VideoDetails } from '@app/shared/shared-main' |
3 | import { VideoState } from '@shared/models' | 4 | import { 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 | }) |
10 | export class VideoAlertComponent { | 11 | export 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' | |||
25 | import { LiveVideoService } from '@app/shared/shared-video-live' | 25 | import { LiveVideoService } from '@app/shared/shared-video-live' |
26 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | 26 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' |
27 | import { logger } from '@root-helpers/logger' | 27 | import { logger } from '@root-helpers/logger' |
28 | import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' | 28 | import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video' |
29 | import { timeToInt } from '@shared/core-utils' | 29 | import { timeToInt } from '@shared/core-utils' |
30 | import { | 30 | import { |
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' | |||
4 | type ConfirmOptions = { | 4 | type 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 | ||
29 | export 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 | |||
29 | export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { | 38 | export 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' | |||
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor, ServerService } from '@app/core' | 5 | import { RestExtractor, ServerService } from '@app/core' |
6 | import { objectToFormData, sortBy } from '@app/helpers' | 6 | import { objectToFormData, sortBy } from '@app/helpers' |
7 | import { VideoService } from '@app/shared/shared-main/video' | 7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' |
8 | import { peertubeTranslate } from '@shared/core-utils/i18n' | 8 | import { peertubeTranslate } from '@shared/core-utils/i18n' |
9 | import { ResultList, VideoCaption } from '@shared/models' | 9 | import { ResultList, VideoCaption } from '@shared/models' |
10 | import { environment } from '../../../../environments/environment' | 10 | import { 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' | |||
5 | export * from './video-file-token.service' | 5 | export * from './video-file-token.service' |
6 | export * from './video-import.service' | 6 | export * from './video-import.service' |
7 | export * from './video-ownership.service' | 7 | export * from './video-ownership.service' |
8 | export * from './video-password.service' | ||
8 | export * from './video.model' | 9 | export * from './video.model' |
9 | export * from './video.resolver' | 10 | export * from './video.resolver' |
10 | export * from './video.service' | 11 | export * 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 @@ | |||
1 | import { getAbsoluteAPIUrl } from '@app/helpers' | 1 | import { getAbsoluteAPIUrl } from '@app/helpers' |
2 | import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' | 2 | import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' |
3 | import { VideoDetails } from './video-details.model' | 3 | import { VideoDetails } from './video-details.model' |
4 | import { objectKeysTyped } from '@shared/core-utils' | 4 | import { 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' | |||
4 | import { RestExtractor } from '@app/core' | 4 | import { RestExtractor } from '@app/core' |
5 | import { VideoToken } from '@shared/models' | 5 | import { VideoToken } from '@shared/models' |
6 | import { VideoService } from './video.service' | 6 | import { VideoService } from './video.service' |
7 | import { VideoPasswordService } from './video-password.service' | ||
7 | 8 | ||
8 | @Injectable() | 9 | @Injectable() |
9 | export class VideoFileTokenService { | 10 | export 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 @@ | |||
1 | import { ResultList, VideoPassword } from '@shared/models' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { catchError, switchMap } from 'rxjs' | ||
4 | import { HttpClient, HttpHeaders } from '@angular/common/http' | ||
5 | import { RestExtractor } from '@app/core' | ||
6 | import { VideoService } from './video.service' | ||
7 | |||
8 | @Injectable() | ||
9 | export 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' | |||
33 | import { VideoDetails } from './video-details.model' | 33 | import { VideoDetails } from './video-details.model' |
34 | import { VideoEdit } from './video-edit.model' | 34 | import { VideoEdit } from './video-edit.model' |
35 | import { Video } from './video.model' | 35 | import { Video } from './video.model' |
36 | import { VideoPasswordService } from './video-password.service' | ||
36 | 37 | ||
37 | export type CommonVideoParams = { | 38 | export 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 { | |||
18 | import { environment } from '../../../environments/environment' | 18 | import { environment } from '../../../environments/environment' |
19 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | 19 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' |
20 | import { VideoComment } from './video-comment.model' | 20 | import { VideoComment } from './video-comment.model' |
21 | import { VideoPasswordService } from '../shared-main' | ||
21 | 22 | ||
22 | @Injectable() | 23 | @Injectable() |
23 | export class VideoCommentService { | 24 | export 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 @@ | |||
1 | import { mapValues } from 'lodash-es' | 1 | import { mapValues } from 'lodash-es' |
2 | import { firstValueFrom } from 'rxjs' | 2 | import { firstValueFrom } from 'rxjs' |
3 | import { tap } from 'rxjs/operators' | 3 | import { tap } from 'rxjs/operators' |
4 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' | 4 | import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core' |
5 | import { HooksService } from '@app/core' | 5 | import { HooksService } from '@app/core' |
6 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' |
7 | import { logger } from '@root-helpers/logger' | 7 | import { logger } from '@root-helpers/logger' |
8 | import { videoRequiresAuth } from '@root-helpers/video' | 8 | import { videoRequiresFileToken } from '@root-helpers/video' |
9 | import { objectKeysTyped, pick } from '@shared/core-utils' | 9 | import { objectKeysTyped, pick } from '@shared/core-utils' |
10 | import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' | 10 | import { VideoCaption, VideoFile } from '@shared/models' |
11 | import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' | 11 | import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' |
12 | 12 | ||
13 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } } | |||
21 | export class VideoDownloadComponent { | 21 | export 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 | ||
44 | function videoRequiresAuth (video: Video) { | 44 | function 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 | |||
50 | function videoRequiresFileToken (video: Video, videoPassword?: string) { | ||
51 | return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id) | ||
46 | } | 52 | } |
47 | 53 | ||
48 | export { | 54 | export { |
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, | |||
24 | body { | 24 | body { |
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' | |||
3 | import '../../assets/player/shared/dock/peertube-dock-plugin' | 3 | import '../../assets/player/shared/dock/peertube-dock-plugin' |
4 | import videojs from 'video.js' | 4 | import videojs from 'video.js' |
5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' | 5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' |
6 | import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' | 6 | import { |
7 | HTMLServerConfig, | ||
8 | ResultList, | ||
9 | ServerErrorCode, | ||
10 | VideoDetails, | ||
11 | VideoPlaylist, | ||
12 | VideoPlaylistElement, | ||
13 | VideoState | ||
14 | } from '../../../../shared/models' | ||
7 | import { PeertubePlayerManager } from '../../assets/player' | 15 | import { PeertubePlayerManager } from '../../assets/player' |
8 | import { TranslationsManager } from '../../assets/player/translations-manager' | 16 | import { TranslationsManager } from '../../assets/player/translations-manager' |
9 | import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' | 17 | import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' |
10 | import { PeerTubeEmbedApi } from './embed-api' | 18 | import { PeerTubeEmbedApi } from './embed-api' |
11 | import { | 19 | import { |
12 | AuthHTTP, | 20 | AuthHTTP, |
@@ -19,6 +27,7 @@ import { | |||
19 | VideoFetcher | 27 | VideoFetcher |
20 | } from './shared' | 28 | } from './shared' |
21 | import { PlayerHTML } from './shared/player-html' | 29 | import { PlayerHTML } from './shared/player-html' |
30 | import { PeerTubeServerError } from 'src/types' | ||
22 | 31 | ||
23 | export class PeerTubeEmbed { | 32 | export 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 | ||
406 | PeerTubeEmbed.main() | 438 | PeerTubeEmbed.main() |
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts index 95e3b029e..c1e9f7750 100644 --- a/client/src/standalone/videos/shared/auth-http.ts +++ b/client/src/standalone/videos/shared/auth-http.ts | |||
@@ -18,10 +18,12 @@ export class AuthHTTP { | |||
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' |
23 | import { PeerTubePlugin } from './peertube-plugin' | 23 | import { PeerTubePlugin } from './peertube-plugin' |
24 | import { PlayerHTML } from './player-html' | 24 | import { 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 @@ | |||
1 | import { PeerTubeServerError } from '../../../types' | ||
1 | import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' | 2 | import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' |
2 | import { logger } from '../../../root-helpers' | 3 | import { logger } from '../../../root-helpers' |
3 | import { AuthHTTP } from './auth-http' | 4 | import { 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 @@ | |||
1 | export * from './client-script.model' | 1 | export * from './client-script.model' |
2 | export * from './server-error.model' | ||
2 | export * from './job-state-client.type' | 3 | export * from './job-state-client.type' |
3 | export * from './job-type-client.type' | 4 | export * from './job-type-client.type' |
4 | export * from './link.type' | 5 | export * from './link.type' |
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts new file mode 100644 index 000000000..4a57287fe --- /dev/null +++ b/client/src/types/server-error.model.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { ServerErrorCode } from '@shared/models/index' | ||
2 | |||
3 | export 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 | } | ||