From 40346ead2b0b7afa475aef057d3673b6c7574b7a Mon Sep 17 00:00:00 2001 From: Wicklow <123956049+wickloww@users.noreply.github.com> Date: Thu, 29 Jun 2023 07:48:55 +0000 Subject: 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 --- .../+admin/overview/videos/video-admin.service.ts | 2 +- .../my-account-two-factor-button.component.ts | 2 +- .../+video-edit/shared/video-edit.component.html | 9 ++- .../+video-edit/shared/video-edit.component.ts | 26 +++++++-- .../+videos/+video-edit/video-update.component.ts | 4 +- .../+videos/+video-edit/video-update.resolver.ts | 20 ++++--- .../action-buttons/action-buttons.component.html | 6 +- .../action-buttons/action-buttons.component.ts | 14 ++++- .../shared/action-buttons/video-rate.component.ts | 5 +- .../shared/comment/video-comment-add.component.ts | 10 +++- .../shared/comment/video-comment.component.html | 2 + .../shared/comment/video-comment.component.ts | 1 + .../shared/comment/video-comments.component.html | 3 + .../shared/comment/video-comments.component.ts | 5 +- .../shared/information/video-alert.component.html | 4 ++ .../shared/information/video-alert.component.ts | 8 ++- .../+video-watch/video-watch.component.html | 7 ++- .../+videos/+video-watch/video-watch.component.ts | 64 ++++++++++++++++++---- client/src/app/core/confirm/confirm.service.ts | 12 +++- client/src/app/modal/confirm.component.html | 6 +- client/src/app/modal/confirm.component.ts | 9 ++- .../app/shared/form-validators/video-validators.ts | 9 +++ .../app/shared/shared-main/shared-main.module.ts | 3 + .../video-caption/video-caption.service.ts | 8 ++- client/src/app/shared/shared-main/video/index.ts | 1 + .../shared/shared-main/video/video-edit.model.ts | 8 ++- .../shared-main/video/video-file-token.service.ts | 11 ++-- .../shared-main/video/video-password.service.ts | 29 ++++++++++ .../app/shared/shared-main/video/video.model.ts | 7 +++ .../app/shared/shared-main/video/video.service.ts | 53 +++++++++++------- .../shared-share-modal/video-share.component.html | 4 ++ .../shared-share-modal/video-share.component.ts | 4 ++ .../shared-video-comment/video-comment.service.ts | 25 ++++++--- .../video-download.component.ts | 15 +++-- .../video-filters-header.component.html | 2 +- .../video-miniature.component.html | 1 + .../video-miniature.component.ts | 4 ++ .../videos-list.component.ts | 3 +- ...video-playlist-element-miniature.component.html | 3 +- .../video-playlist-element-miniature.component.ts | 4 ++ .../shared/manager-options/hls-options-builder.ts | 17 ++++-- .../manager-options/webtorrent-options-builder.ts | 4 +- .../shared/p2p-media-loader/segment-validator.ts | 40 ++++++++++---- .../player/shared/webtorrent/webtorrent-plugin.ts | 6 +- client/src/assets/player/types/manager-options.ts | 4 +- .../player/types/peertube-videojs-typings.ts | 4 +- client/src/root-helpers/video.ts | 13 ++++- client/src/standalone/videos/embed.html | 17 ++++++ client/src/standalone/videos/embed.scss | 43 ++++++++++++++- client/src/standalone/videos/embed.ts | 44 +++++++++++++-- client/src/standalone/videos/shared/auth-http.ts | 10 ++-- client/src/standalone/videos/shared/player-html.ts | 52 ++++++++++++++++++ .../videos/shared/player-manager-options.ts | 12 +++- .../src/standalone/videos/shared/video-fetcher.ts | 24 ++++---- client/src/types/index.ts | 1 + client/src/types/server-error.model.ts | 11 ++++ 56 files changed, 572 insertions(+), 143 deletions(-) create mode 100644 client/src/app/shared/shared-main/video/video-password.service.ts create mode 100644 client/src/types/server-error.model.ts (limited to 'client') 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 { } if (filters.excludePublic) { - privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] + privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ] filters.excludePublic = undefined } 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 { async disableTwoFactor () { const message = $localize`Are you sure you want to disable two factor authentication of your account?` - const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) + const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` }) if (confirmed === false) return this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) 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 @@ -
+
+ + +
+ +
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 { VIDEO_LICENCE_VALIDATOR, VIDEO_NAME_VALIDATOR, VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, + VIDEO_PASSWORD_VALIDATOR, VIDEO_PRIVACY_VALIDATOR, VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, VIDEO_SUPPORT_VALIDATOR, @@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { // So that it can be accessed in the template readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY - videoPrivacies: VideoConstant[] = [] + videoPrivacies: VideoConstant [] = [] + replayPrivacies: VideoConstant [] = [] videoCategories: VideoConstant[] = [] videoLicences: VideoConstant[] = [] videoLanguages: VideoLanguages[] = [] @@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { pluginDataFormGroup: FormGroup - schedulePublicationEnabled = false + schedulePublicationSelected = false + passwordProtectionSelected = false calendarLocale: any = {} minScheduledDate = new Date() @@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { const obj: { [ id: string ]: BuildFormValidator } = { name: VIDEO_NAME_VALIDATOR, privacy: VIDEO_PRIVACY_VALIDATOR, + videoPassword: VIDEO_PASSWORD_VALIDATOR, channelId: VIDEO_CHANNEL_VALIDATOR, nsfw: null, commentsEnabled: null, @@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.serverService.getVideoPrivacies() .subscribe(privacies => { - this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies + const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies + this.videoPrivacies = videoPrivacies + this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED) // Can't schedule publication if private privacy is not available (could be deleted by a plugin) const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) @@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy { .subscribe( newPrivacyId => { - this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY + this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY // Value changed const scheduleControl = this.form.get('schedulePublicationAt') const waitTranscodingControl = this.form.get('waitTranscoding') - if (this.schedulePublicationEnabled) { + if (this.schedulePublicationSelected) { scheduleControl.setValidators([ Validators.required ]) waitTranscodingControl.disable() @@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.firstPatchDone = true + this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED + const videoPasswordControl = this.form.get('videoPassword') + + if (this.passwordProtectionSelected) { + videoPasswordControl.setValidators([ Validators.required ]) + } else { + videoPasswordControl.clearValidators() + } + videoPasswordControl.updateValueAndValidity() + } ) } 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 { this.buildForm({}) const { videoData } = this.route.snapshot.data - const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData + const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData this.videoDetails = video - this.videoEdit = new VideoEdit(this.videoDetails) + this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) this.userVideoChannels = videoChannels this.videoCaptions = videoCaptions 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' import { ActivatedRouteSnapshot } from '@angular/router' import { AuthService } from '@app/core' import { listUserChannelsForSelect } from '@app/helpers' -import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' +import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main' import { LiveVideoService } from '@app/shared/shared-video-live' +import { VideoPrivacy } from '@shared/models/videos' @Injectable() export class VideoUpdateResolver { @@ -13,7 +14,8 @@ export class VideoUpdateResolver { private videoService: VideoService, private liveVideoService: LiveVideoService, private authService: AuthService, - private videoCaptionService: VideoCaptionService + private videoCaptionService: VideoCaptionService, + private videoPasswordService: VideoPasswordService ) { } @@ -21,11 +23,11 @@ export class VideoUpdateResolver { const uuid: string = route.params['uuid'] return this.videoService.getVideo({ videoId: uuid }) - .pipe( - switchMap(video => forkJoin(this.buildVideoObservables(video))), - map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) => - ({ video, videoChannels, videoCaptions, videoSource, liveVideo })) - ) + .pipe( + switchMap(video => forkJoin(this.buildVideoObservables(video))), + map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => + ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) + ) } private buildVideoObservables (video: VideoDetails) { @@ -46,6 +48,10 @@ export class VideoUpdateResolver { video.isLive ? this.liveVideoService.getVideoLive(video.id) + : of(undefined), + + video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED + ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid }) : of(undefined) ] } 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 @@
@@ -20,7 +20,7 @@
@@ -43,7 +43,7 @@ DOWNLOAD - + 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' import { SupportModalComponent } from '@app/shared/shared-support-modal' import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' import { VideoPlaylist } from '@app/shared/shared-video-playlist' -import { UserVideoRateType, VideoCaption } from '@shared/models/videos' +import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos' @Component({ selector: 'my-action-buttons', @@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges { @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent @Input() video: VideoDetails + @Input() videoPassword: string @Input() videoCaptions: VideoCaption[] @Input() playlist: VideoPlaylist @Input() isUserLoggedIn: boolean + @Input() isUserOwner: boolean @Input() currentTime: number @Input() currentPlaylistPosition: number @@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges { private setVideoLikesBarTooltipText () { this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` } + + isVideoAddableToPlaylist () { + const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED + + if (!this.isUserLoggedIn) return false + + if (isPasswordProtected) return this.isUserOwner + + return true + } } 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' }) export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { @Input() video: VideoDetails + @Input() videoPassword: string @Input() isUserLoggedIn: boolean @Output() userRatingLoaded = new EventEmitter() @@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { } private setRating (nextRating: UserVideoRateType) { - const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable } = { + const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable } = { like: this.videoService.setVideoLike, dislike: this.videoService.setVideoDislike, none: this.videoService.unsetVideoLike } - ratingMethods[nextRating].call(this.videoService, this.video.uuid) + ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword) .subscribe({ next: () => { // Update the video like attribute 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' export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { @Input() user: User @Input() video: Video + @Input() videoPassword: string @Input() parentComment?: VideoComment @Input() parentComments?: VideoComment[] @Input() focusOnInit = false @@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges, private addCommentReply (commentCreate: VideoCommentCreate) { return this.videoCommentService - .addCommentReply(this.video.uuid, this.parentComment.id, commentCreate) + .addCommentReply({ + videoId: this.video.uuid, + inReplyToCommentId: this.parentComment.id, + comment: commentCreate, + videoPassword: this.videoPassword + }) } private addCommentThread (commentCreate: VideoCommentCreate) { return this.videoCommentService - .addCommentThread(this.video.uuid, commentCreate) + .addCommentThread(this.video.uuid, commentCreate, this.videoPassword) } private initTextValue () { 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 @@ *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" [user]="user" [video]="video" + [videoPassword]="videoPassword" [parentComment]="comment" [parentComments]="newParentComments" [focusOnInit]="true" @@ -75,6 +76,7 @@ () @@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { const params = { videoId: this.video.uuid, - threadId: commentId + threadId: commentId, + videoPassword: this.videoPassword } const obs = this.hooks.wrapObsFun( @@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { loadMoreThreads () { const params = { videoId: this.video.uuid, + videoPassword: this.videoPassword, componentPagination: this.componentPagination, sort: this.sort } 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 @@
This video is blocked.
{{ video.blacklistedReason }}
+ +
+ This video is password protected. +
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 @@ import { Component, Input } from '@angular/core' +import { AuthUser } from '@app/core' import { VideoDetails } from '@app/shared/shared-main' -import { VideoState } from '@shared/models' +import { VideoPrivacy, VideoState } from '@shared/models' @Component({ selector: 'my-video-alert', @@ -8,6 +9,7 @@ import { VideoState } from '@shared/models' styleUrls: [ './video-alert.component.scss' ] }) export class VideoAlertComponent { + @Input() user: AuthUser @Input() video: VideoDetails @Input() noPlaylistVideoFound: boolean @@ -46,4 +48,8 @@ export class VideoAlertComponent { isLiveEnded () { return this.video?.state.id === VideoState.LIVE_ENDED } + + isVideoPasswordProtected () { + return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED + } } 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 @@
- +
@@ -51,8 +51,8 @@
@@ -92,6 +92,7 @@ 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' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { logger } from '@root-helpers/logger' -import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' +import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video' import { timeToInt } from '@shared/core-utils' import { HTMLServerConfig, @@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails = null videoCaptions: VideoCaption[] = [] liveVideo: LiveVideo + videoPassword: string playlistPosition: number playlist: VideoPlaylist = null @@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { return this.authService.isLoggedIn() } + isUserOwner () { + return this.video.isLocal === true && this.video.account.name === this.user?.username + } + isVideoBlur (video: Video) { return video.isVideoNSFWForUser(this.user, this.serverConfig) } @@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private loadVideo (options: { videoId: string forceAutoplay: boolean + videoPassword?: string }) { - const { videoId, forceAutoplay } = options + const { videoId, forceAutoplay, videoPassword } = options if (this.isSameElement(this.video, videoId)) return @@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { const videoObs = this.hooks.wrapObsFun( this.videoService.getVideo.bind(this.videoService), - { videoId }, + { videoId, videoPassword }, 'video-watch', 'filter:api.video-watch.video.get.params', 'filter:api.video-watch.video.get.result' @@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy { }), switchMap(({ video, live }) => { - if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) + if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined }) - return this.videoFileTokenService.getVideoFileToken(video.uuid) + return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword }) .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) }) ) forkJoin([ videoAndLiveObs, - this.videoCaptionService.listCaptions(videoId), + this.videoCaptionService.listCaptions(videoId, videoPassword), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { @@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy { live, videoCaptions: captionsResult.data, videoFileToken, + videoPassword, loggedInOrAnonymousUser, urlOptions, forceAutoplay - }).catch(err => this.handleGlobalError(err)) + }).catch(err => { + this.handleGlobalError(err) + }) }, + error: async err => { + if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) { + const { confirmed, password } = await this.handleVideoPasswordError(err) + + if (confirmed === false) return this.location.back() - error: err => this.handleRequestError(err) + this.loadVideo({ ...options, videoPassword: password }) + } else { + this.handleRequestError(err) + } + } }) } @@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.notifier.error(errorMessage) } + private handleVideoPasswordError (err: any) { + let isIncorrectPassword: boolean + + if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) { + isIncorrectPassword = false + } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) { + this.videoPassword = undefined + isIncorrectPassword = true + } + + return this.confirmService.confirmWithPassword({ + message: $localize`You need a password to watch this video`, + title: $localize`This video is password protected`, + errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : '' + }) + } + private async onVideoFetched (options: { video: VideoDetails live: LiveVideo videoCaptions: VideoCaption[] videoFileToken: string + videoPassword: string urlOptions: URLOptions loggedInOrAnonymousUser: User forceAutoplay: boolean }) { - const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options + const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options this.subscribeToLiveEventsIfNeeded(this.video, video) @@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.videoCaptions = videoCaptions this.liveVideo = live this.videoFileToken = videoFileToken + this.videoPassword = videoPassword // Re init attributes this.playerPlaceholderImgSrc = undefined @@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoCaptions: this.videoCaptions, liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, + videoPassword: this.videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay, @@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoCaptions: VideoCaption[] videoFileToken: string + videoPassword: string urlOptions: CustomizationOptions & { playerMode: PlayerMode } @@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { forceAutoplay: boolean user?: AuthUser // Keep for plugins }) { - const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params + const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params const getStartTime = () => { const byUrl = urlOptions.startTime !== undefined @@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { serverUrl: environment.originServerUrl || window.location.origin, videoFileToken: () => videoFileToken, - requiresAuth: videoRequiresAuth(video), + requiresUserAuth: videoRequiresUserAuth(video, videoPassword), + requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && + !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), + videoPassword: () => videoPassword, videoCaptions: playerCaptions, 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' type ConfirmOptions = { title: string message: string + errorMessage?: string } & ( { type: 'confirm' @@ -12,6 +13,7 @@ type ConfirmOptions = { { type: 'confirm-password' confirmButtonText?: string + isIncorrectPassword?: boolean } | { type: 'confirm-expected-input' @@ -32,8 +34,14 @@ export class ConfirmService { return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) } - confirmWithPassword (message: string, title = '', confirmButtonText?: string) { - this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) + confirmWithPassword (options: { + message: string + title?: string + confirmButtonText?: string + errorMessage?: string + }) { + const { message, title = '', confirmButtonText, errorMessage } = options + this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage }) const obs = this.confirmResponse.asObservable() .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) 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 @@
- + - +
+ +
{{ errorMessage }}
+
+ This video is password protected, please note that recipients will require the corresponding password to access the content. +
+ 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 @@ > Unlisted Private + Password protected
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 { return this.video.privacy.id === VideoPrivacy.PRIVATE } + isPasswordProtectedVideo () { + return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED + } + getStateLabel (video: Video) { if (!video.state) return '' 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 { } reloadVideos () { + console.log('reload') this.pagination.currentPage = 1 this.loadMoreVideos(true) } @@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { if (reset) this.videos = [] this.videos = this.videos.concat(data) - + console.log('subscribe') if (this.groupByDate) this.buildGroupedDateLabels() this.onDataSubject.next(data) 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 @@ [attr.title]="playlistElement.video.name" >{{ playlistElement.video.name }} - Private + Private + Password protected
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 { return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE } + isVideoPasswordProtected () { + return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED + } + isUnavailable (e: VideoPlaylistElement) { return e.type === VideoPlaylistElementType.UNAVAILABLE } 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 { const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader const p2pMediaLoader: P2PMediaLoaderPluginOptions = { - requiresAuth: commonOptions.requiresAuth, + requiresUserAuth: commonOptions.requiresUserAuth, videoFileToken: commonOptions.videoFileToken, redundancyUrlManager, @@ -88,17 +88,24 @@ export class HLSOptionsBuilder { httpFailedSegmentTimeout: 1000, xhrSetup: (xhr, url) => { - if (!this.options.common.requiresAuth) return + const { requiresUserAuth, requiresPassword } = this.options.common + + if (!(requiresUserAuth || requiresPassword)) return + if (!isSameOrigin(this.options.common.serverUrl, url)) return - xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) + if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword()) + + else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) }, segmentValidator: segmentValidatorFactory({ segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, authorizationHeader: this.options.common.authorizationHeader, - requiresAuth: this.options.common.requiresAuth, - serverUrl: this.options.common.serverUrl + requiresUserAuth: this.options.common.requiresUserAuth, + serverUrl: this.options.common.serverUrl, + requiresPassword: this.options.common.requiresPassword, + videoPassword: this.options.common.videoPassword }), segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), 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 { videoFileToken: commonOptions.videoFileToken, - requiresAuth: commonOptions.requiresAuth, + requiresUserAuth: commonOptions.requiresUserAuth, buildWebSeedUrls: file => { - if (!commonOptions.requiresAuth) return [] + if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return [] return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] }, 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: { serverUrl: string segmentsSha256Url: string authorizationHeader: () => string - requiresAuth: boolean + requiresUserAuth: boolean + requiresPassword: boolean + videoPassword: () => string }) { - const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options - - let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) + const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options + + let segmentsJSON = fetchSha256Segments({ + serverUrl, + segmentsSha256Url, + authorizationHeader, + requiresUserAuth, + requiresPassword, + videoPassword + }) const regex = /bytes=(\d+)-(\d+)/ return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { @@ -34,7 +43,14 @@ function segmentValidatorFactory (options: { await wait(500) - segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) + segmentsJSON = fetchSha256Segments({ + serverUrl, + segmentsSha256Url, + authorizationHeader, + requiresUserAuth, + requiresPassword, + videoPassword + }) await segmentValidator(segment, _method, _peerId, retry + 1) return @@ -78,13 +94,17 @@ function fetchSha256Segments (options: { serverUrl: string segmentsSha256Url: string authorizationHeader: () => string - requiresAuth: boolean + requiresUserAuth: boolean + requiresPassword: boolean + videoPassword: () => string }): Promise { - const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options + const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options - const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url) - ? { Authorization: authorizationHeader() } - : {} + let headers: { [ id: string ]: string } = {} + if (isSameOrigin(serverUrl, segmentsSha256Url)) { + if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() } + else if (requiresUserAuth) headers = { Authorization: authorizationHeader() } + } return fetch(segmentsSha256Url, { headers }) .then(res => res.json() as Promise) 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 { private isAutoResolutionObservation = false private playerRefusedP2P = false - private requiresAuth: boolean + private requiresUserAuth: boolean private videoFileToken: () => string private torrentInfoInterval: any @@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin { this.savePlayerSrcFunction = this.player.src this.playerElement = options.playerElement - this.requiresAuth = options.requiresAuth + this.requiresUserAuth = options.requiresUserAuth this.videoFileToken = options.videoFileToken this.buildWebSeedUrls = options.buildWebSeedUrls @@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin { let httpUrl = this.currentVideoFile.fileUrl - if (this.requiresAuth && this.videoFileToken) { + if (this.videoFileToken) { httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) } 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 { videoShortUUID: string serverUrl: string - requiresAuth: boolean + requiresUserAuth: boolean videoFileToken: () => string + requiresPassword: boolean + videoPassword: () => string errorNotifier: (message: string) => void } 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 = { playerRefusedP2P: boolean - requiresAuth: boolean + requiresUserAuth: boolean videoFileToken: () => string buildWebSeedUrls: (file: VideoFile) => string[] @@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = { loader: P2PMediaLoader - requiresAuth: boolean + requiresUserAuth: boolean videoFileToken: () => string } 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 return userP2PEnabled } -function videoRequiresAuth (video: Video) { - return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) +function videoRequiresUserAuth (video: Video, videoPassword?: string) { + return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) || + (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword) + +} + +function videoRequiresFileToken (video: Video, videoPassword?: string) { + return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id) } export { buildVideoOrPlaylistEmbed, isP2PEnabled, - videoRequiresAuth + videoRequiresUserAuth, + videoRequiresFileToken } // --------------------------------------------------------------------------- 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 @@
+
+ +

+ +
+ +
+ + +
+ +
+ + + +
+
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, body { height: 100%; margin: 0; - background-color: #000; + background-color: #0f0f10; } #video-wrapper { @@ -42,8 +42,10 @@ body { } } -#error-block { +#error-block, +#video-password-block { display: none; + user-select: none; flex-direction: column; align-content: center; @@ -86,6 +88,43 @@ body { text-align: center; } +#video-password-content { + @include margin(1rem, 0, 2rem); +} + +#video-password-input, +#video-password-submit { + line-height: 23px; + padding: 1rem; + margin: 1rem 0.5rem; + border: 0; + font-weight: 600; + border-radius: 3px!important; + font-size: 18px; + display: inline-block; +} + +#video-password-submit { + color: #fff; + background-color: #f2690d; + cursor: pointer; +} + +#video-password-submit:hover { + background-color: #f47825; +} +#video-password-error { + margin-top: 10px; + margin-bottom: 10px; + height: 2rem; + font-weight: bolder; +} + +#video-password-block svg { + margin-left: auto; + margin-right: auto; +} + @media screen and (max-width: 300px) { #error-block { font-size: 36px; 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' import '../../assets/player/shared/dock/peertube-dock-plugin' import videojs from 'video.js' import { peertubeTranslate } from '../../../../shared/core-utils/i18n' -import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' +import { + HTMLServerConfig, + ResultList, + ServerErrorCode, + VideoDetails, + VideoPlaylist, + VideoPlaylistElement, + VideoState +} from '../../../../shared/models' import { PeertubePlayerManager } from '../../assets/player' import { TranslationsManager } from '../../assets/player/translations-manager' -import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' +import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' import { PeerTubeEmbedApi } from './embed-api' import { AuthHTTP, @@ -19,6 +27,7 @@ import { VideoFetcher } from './shared' import { PlayerHTML } from './shared/player-html' +import { PeerTubeServerError } from 'src/types' export class PeerTubeEmbed { player: videojs.Player @@ -38,6 +47,8 @@ export class PeerTubeEmbed { private readonly liveManager: LiveManager private playlistTracker: PlaylistTracker + private videoPassword: string + private requiresPassword: boolean constructor (videoWrapperId: string) { logger.registerServerSending(window.location.origin) @@ -50,6 +61,7 @@ export class PeerTubeEmbed { this.playerHTML = new PlayerHTML(videoWrapperId) this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) this.liveManager = new LiveManager(this.playerHTML) + this.requiresPassword = false try { this.config = JSON.parse((window as any)['PeerTubeServerConfig']) @@ -176,11 +188,13 @@ export class PeerTubeEmbed { const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options try { - const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) + const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) } catch (err) { - this.playerHTML.displayError(err.message, await this.translationsPromise) + + if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) + else this.playerHTML.displayError(err.message, await this.translationsPromise) } } @@ -205,8 +219,8 @@ export class PeerTubeEmbed { ? await this.videoFetcher.loadLive(videoInfo) : undefined - const videoFileToken = videoRequiresAuth(videoInfo) - ? await this.videoFetcher.loadVideoToken(videoInfo) + const videoFileToken = videoRequiresFileToken(videoInfo) + ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword) : undefined return { live, video: videoInfo, videoFileToken } @@ -232,6 +246,8 @@ export class PeerTubeEmbed { authorizationHeader: () => this.http.getHeaderTokenValue(), videoFileToken: () => videoFileToken, + videoPassword: () => this.videoPassword, + requiresPassword: this.requiresPassword, onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), @@ -263,6 +279,7 @@ export class PeerTubeEmbed { this.initializeApi() this.playerHTML.removePlaceholder() + if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock() if (this.isPlaylistEmbed()) { await this.buildPlayerPlaylistUpnext() @@ -401,6 +418,21 @@ export class PeerTubeEmbed { (this.player.el() as HTMLElement).style.pointerEvents = 'none' } + private async handlePasswordError (err: PeerTubeServerError) { + let incorrectPassword: boolean = null + if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false + else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true + + if (incorrectPassword === null) return false + + this.requiresPassword = true + this.videoPassword = await this.playerHTML.askVideoPassword({ + incorrectPassword, + translations: await this.translationsPromise + }) + return true + } + } PeerTubeEmbed.main() 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 { if (this.userOAuthTokens) this.setHeadersFromTokens() } - fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { - const refreshFetchOptions = optionalAuth - ? { headers: this.headers } - : {} + fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) { + let refreshFetchOptions: { headers?: Headers } = {} + + if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword) + + if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers } return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) } 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 { this.wrapperElement.style.display = 'none' } + async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise { + const { incorrectPassword, translations } = options + return new Promise((resolve) => { + + this.removePlaceholder() + this.wrapperElement.style.display = 'none' + + const translatedTitle = peertubeTranslate('This video is password protected', translations) + const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations) + + document.title = translatedTitle + + const videoPasswordBlock = document.getElementById('video-password-block') + videoPasswordBlock.style.display = 'flex' + + const videoPasswordTitle = document.getElementById('video-password-title') + videoPasswordTitle.innerHTML = translatedTitle + + const videoPasswordMessage = document.getElementById('video-password-content') + videoPasswordMessage.innerHTML = translatedMessage + + if (incorrectPassword) { + const videoPasswordError = document.getElementById('video-password-error') + videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations) + videoPasswordError.style.transform = 'scale(1.2)' + + setTimeout(() => { + videoPasswordError.style.transform = 'scale(1)' + }, 500) + } + + const videoPasswordSubmitButton = document.getElementById('video-password-submit') + videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations) + + const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement + videoPasswordInput.placeholder = peertubeTranslate('Password', translations) + + const videoPasswordForm = document.getElementById('video-password-form') + videoPasswordForm.addEventListener('submit', (event) => { + event.preventDefault() + const videoPassword = videoPasswordInput.value + resolve(videoPassword) + }) + }) + } + + removeVideoPasswordBlock () { + const videoPasswordBlock = document.getElementById('video-password-block') + videoPasswordBlock.style.display = 'none' + this.wrapperElement.style.display = 'block' + } + buildPlaceholder (video: VideoDetails) { const placeholder = this.getPlaceholderElement() 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 { logger, peertubeLocalStorage, UserLocalStorageKeys, - videoRequiresAuth + videoRequiresUserAuth } from '../../../root-helpers' import { PeerTubePlugin } from './peertube-plugin' import { PlayerHTML } from './player-html' @@ -162,6 +162,9 @@ export class PlayerManagerOptions { authorizationHeader: () => string videoFileToken: () => string + videoPassword: () => string + requiresPassword: boolean + serverConfig: HTMLServerConfig autoplayFromPreviousVideo: boolean @@ -178,6 +181,8 @@ export class PlayerManagerOptions { captionsResponse, autoplayFromPreviousVideo, videoFileToken, + videoPassword, + requiresPassword, translations, forceAutoplay, playlistTracker, @@ -242,10 +247,13 @@ export class PlayerManagerOptions { embedUrl: window.location.origin + video.embedPath, embedTitle: video.name, - requiresAuth: videoRequiresAuth(video), + requiresUserAuth: videoRequiresUserAuth(video), authorizationHeader, videoFileToken, + requiresPassword, + videoPassword, + errorNotifier: () => { // Empty, we don't have a notifier in the embed }, 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 @@ +import { PeerTubeServerError } from '../../../types' import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' import { logger } from '../../../root-helpers' import { AuthHTTP } from './auth-http' @@ -8,8 +9,8 @@ export class VideoFetcher { } - async loadVideo (videoId: string) { - const videoPromise = this.loadVideoInfo(videoId) + async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) { + const videoPromise = this.loadVideoInfo({ videoId, videoPassword }) let videoResponse: Response let isResponseOk: boolean @@ -27,11 +28,14 @@ export class VideoFetcher { if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { throw new Error('This video does not exist.') } - + if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) { + const res = await videoResponse.json() + throw new PeerTubeServerError(res.message, res.code) + } throw new Error('We cannot fetch the video. Please try again later.') } - const captionsPromise = this.loadVideoCaptions(videoId) + const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) return { captionsPromise, videoResponse } } @@ -41,8 +45,8 @@ export class VideoFetcher { .then(res => res.json() as Promise) } - loadVideoToken (video: VideoDetails) { - return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) + loadVideoToken (video: VideoDetails, videoPassword?: string) { + return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword) .then(res => res.json() as Promise) .then(token => token.files.token) } @@ -51,12 +55,12 @@ export class VideoFetcher { return this.getVideoUrl(videoUUID) + '/views' } - private loadVideoInfo (videoId: string): Promise { - return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) + private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise { + return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword) } - private loadVideoCaptions (videoId: string): Promise { - return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) + private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise { + return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) } 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 @@ export * from './client-script.model' +export * from './server-error.model' export * from './job-state-client.type' export * from './job-type-client.type' export * from './link.type' 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 @@ +import { ServerErrorCode } from '@shared/models/index' + +export class PeerTubeServerError extends Error { + serverCode: ServerErrorCode + + constructor (message: string, serverCode: ServerErrorCode) { + super(message) + this.name = 'CustomError' + this.serverCode = serverCode + } +} -- cgit v1.2.3