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) - error: err => this.handleRequestError(err) + if (confirmed === false) return this.location.back() + + this.loadVideo({ ...options, videoPassword: password }) + } else { + this.handleRequestError(err) + } + } }) } @@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.notifier.error(errorMessage) } + 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 + const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options - let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) + let segmentsJSON = fetchSha256Segments({ + serverUrl, + segmentsSha256Url, + authorizationHeader, + requiresUserAuth, + requiresPassword, + videoPassword + }) const regex = /bytes=(\d+)-(\d+)/ 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 + } +} diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 72136614c..d03d0fe83 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -69,7 +69,10 @@ const playerKeys = { '{1} from servers · {2} from peers': '{1} from servers · {2} from peers', 'Previous video': 'Previous video', 'Video page (new window)': 'Video page (new window)', - 'Next video': 'Next video' + 'Next video': 'Next video', + 'This video is password protected': 'This video is password protected', + 'You need a password to watch this video.': 'You need a password to watch this video.', + 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password' } Object.assign(playerKeys, videojs) diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index 6a50aaf4e..b8016140e 100644 --- a/server/controllers/api/videos/import.ts +++ b/server/controllers/api/videos/import.ts @@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response, videoChannel: res.locals.videoChannel, tags: body.tags || undefined, user, + videoPasswords: body.videoPasswords, videoImportAttributes: { magnetUri, torrentName, diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index a34325e79..d0eecf812 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding' import { updateRouter } from './update' import { uploadRouter } from './upload' import { viewRouter } from './view' +import { videoPasswordRouter } from './passwords' const auditLogger = auditLoggerFactory('videos') const videosRouter = express.Router() @@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter) videosRouter.use('/', filesRouter) videosRouter.use('/', transcodingRouter) videosRouter.use('/', tokenRouter) +videosRouter.use('/', videoPasswordRouter) videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts index de047d4ec..cf82c9791 100644 --- a/server/controllers/api/videos/live.ts +++ b/server/controllers/api/videos/live.ts @@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live' import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' import { buildUUID, uuidToShort } from '@shared/extra-utils' -import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' +import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models' import { logger } from '../../../helpers/logger' import { sequelizeTypescript } from '../../../initializers/database' import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' import { VideoModel } from '../../../models/video/video' import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' +import { VideoPasswordModel } from '@server/models/video/video-password' const liveRouter = express.Router() @@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) { await federateVideoIfNeeded(videoCreated, true, t) + if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t) + } + logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) return { videoCreated } diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts new file mode 100644 index 000000000..d11cf5bcc --- /dev/null +++ b/server/controllers/api/videos/passwords.ts @@ -0,0 +1,105 @@ +import express from 'express' + +import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' +import { getFormattedObjects } from '../../../helpers/utils' +import { + asyncMiddleware, + asyncRetryTransactionMiddleware, + authenticate, + setDefaultPagination, + setDefaultSort +} from '../../../middlewares' +import { + listVideoPasswordValidator, + paginationValidator, + removeVideoPasswordValidator, + updateVideoPasswordListValidator, + videoPasswordsSortValidator +} from '../../../middlewares/validators' +import { VideoPasswordModel } from '@server/models/video/video-password' +import { logger, loggerTagsFactory } from '@server/helpers/logger' +import { Transaction } from 'sequelize' +import { getVideoWithAttributes } from '@server/helpers/video' + +const lTags = loggerTagsFactory('api', 'video') +const videoPasswordRouter = express.Router() + +videoPasswordRouter.get('/:videoId/passwords', + authenticate, + paginationValidator, + videoPasswordsSortValidator, + setDefaultSort, + setDefaultPagination, + asyncMiddleware(listVideoPasswordValidator), + asyncMiddleware(listVideoPasswords) +) + +videoPasswordRouter.put('/:videoId/passwords', + authenticate, + asyncMiddleware(updateVideoPasswordListValidator), + asyncMiddleware(updateVideoPasswordList) +) + +videoPasswordRouter.delete('/:videoId/passwords/:passwordId', + authenticate, + asyncMiddleware(removeVideoPasswordValidator), + asyncRetryTransactionMiddleware(removeVideoPassword) +) + +// --------------------------------------------------------------------------- + +export { + videoPasswordRouter +} + +// --------------------------------------------------------------------------- + +async function listVideoPasswords (req: express.Request, res: express.Response) { + const options = { + videoId: res.locals.videoAll.id, + start: req.query.start, + count: req.query.count, + sort: req.query.sort + } + + const resultList = await VideoPasswordModel.listPasswords(options) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function updateVideoPasswordList (req: express.Request, res: express.Response) { + const videoInstance = getVideoWithAttributes(res) + const videoId = videoInstance.id + + const passwordArray = req.body.passwords as string[] + + await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => { + await VideoPasswordModel.deleteAllPasswords(videoId, t) + await VideoPasswordModel.addPasswords(passwordArray, videoId, t) + }) + + logger.info( + `Video passwords for video with name %s and uuid %s have been updated`, + videoInstance.name, + videoInstance.uuid, + lTags(videoInstance.uuid) + ) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function removeVideoPassword (req: express.Request, res: express.Response) { + const videoInstance = getVideoWithAttributes(res) + const password = res.locals.videoPassword + + await VideoPasswordModel.deletePassword(password.id) + logger.info( + 'Password with id %d of video named %s and uuid %s has been deleted.', + password.id, + videoInstance.name, + videoInstance.uuid, + lTags(videoInstance.uuid) + ) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts index 22387c3e8..e961ffd9e 100644 --- a/server/controllers/api/videos/token.ts +++ b/server/controllers/api/videos/token.ts @@ -1,13 +1,14 @@ import express from 'express' import { VideoTokensManager } from '@server/lib/video-tokens-manager' -import { VideoToken } from '@shared/models' -import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' +import { VideoPrivacy, VideoToken } from '@shared/models' +import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares' const tokenRouter = express.Router() tokenRouter.post('/:id/token', - authenticate, + optionalAuthenticate, asyncMiddleware(videosCustomGetValidator('only-video')), + videoFileTokenValidator, generateToken ) @@ -22,12 +23,11 @@ export { function generateToken (req: express.Request, res: express.Response) { const video = res.locals.onlyVideo - const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) + const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED + ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid }) + : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) return res.json({ - files: { - token, - expires - } + files } as VideoToken) } diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts index ddab428d4..28ec2cf37 100644 --- a/server/controllers/api/videos/update.ts +++ b/server/controllers/api/videos/update.ts @@ -2,13 +2,12 @@ import express from 'express' import { Transaction } from 'sequelize/types' import { changeVideoChannelShare } from '@server/lib/activitypub/share' import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' -import { VideoPathManager } from '@server/lib/video-path-manager' import { setVideoPrivacy } from '@server/lib/video-privacy' import { openapiOperationDoc } from '@server/middlewares/doc' import { FilteredModelAttributes } from '@server/types' import { MVideoFullLight } from '@server/types/models' import { forceNumber } from '@shared/core-utils' -import { HttpStatusCode, VideoUpdate } from '@shared/models' +import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { resetSequelizeInstance } from '../../../helpers/database-utils' import { createReqFiles } from '../../../helpers/express-utils' @@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { VideoModel } from '../../../models/video/video' +import { VideoPathManager } from '@server/lib/video-path-manager' +import { VideoPasswordModel } from '@server/models/video/video-password' +import { exists } from '@server/helpers/custom-validators/misc' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: { const newPrivacy = forceNumber(videoInfoToUpdate.privacy) setVideoPrivacy(videoInstance, newPrivacy) + // Delete passwords if video is not anymore password protected + if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) { + await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction) + } + + if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) { + await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction) + await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction) + } + // Unfederate the video if the new privacy is not compatible with federation if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { await VideoModel.sendDelete(videoInstance, { transaction }) diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts index 885ac8b81..073eb480f 100644 --- a/server/controllers/api/videos/upload.ts +++ b/server/controllers/api/videos/upload.ts @@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc' import { VideoSourceModel } from '@server/models/video/video-source' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' import { uuidToShort } from '@shared/extra-utils' -import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models' +import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { createReqFiles } from '../../../helpers/express-utils' import { logger, loggerTagsFactory } from '../../../helpers/logger' @@ -33,6 +33,7 @@ import { } from '../../../middlewares' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { VideoModel } from '../../../models/video/video' +import { VideoPasswordModel } from '@server/models/video/video-password' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -195,6 +196,10 @@ async function addVideo (options: { transaction: t }) + if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t) + } + auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 5f75ec27c..91109217c 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -1,7 +1,7 @@ -import { UploadFilesForCheck } from 'express' +import { Response, Request, UploadFilesForCheck } from 'express' import { decode as magnetUriDecode } from 'magnet-uri' import validator from 'validator' -import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' +import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' import { CONSTRAINTS_FIELDS, MIMETYPES, @@ -13,6 +13,7 @@ import { VIDEO_STATES } from '../../initializers/constants' import { exists, isArray, isDateValid, isFileValid } from './misc' +import { getVideoWithAttributes } from '@server/helpers/video' const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS @@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) { return VIDEO_PRIVACIES[value] !== undefined } +function isVideoReplayPrivacyValid (value: number) { + return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED +} + function isScheduleVideoUpdatePrivacyValid (value: number) { return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL } @@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) { return parsed && isVideoFileInfoHashValid(parsed.infoHash) } +function isPasswordValid (password: string) { + return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min && + password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max +} + +function isValidPasswordProtectedPrivacy (req: Request, res: Response) { + const fail = (message: string) => { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message + }) + return false + } + + let privacy: VideoPrivacy + const video = getVideoWithAttributes(res) + + if (exists(req.body?.privacy)) privacy = req.body.privacy + else if (exists(video?.privacy)) privacy = video.privacy + + if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true + + if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.') + + const passwords = req.body.videoPasswords || req.body.passwords + + if (passwords.length === 0) return fail('At least one video password is required.') + + if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.') + + for (const password of passwords) { + if (typeof password !== 'string') { + return fail('Video password should be a string.') + } + + if (!isPasswordValid(password)) { + return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.') + } + } + + return true +} + // --------------------------------------------------------------------------- export { @@ -164,9 +212,12 @@ export { isVideoDurationValid, isVideoTagValid, isVideoPrivacyValid, + isVideoReplayPrivacyValid, isVideoFileResolutionValid, isVideoFileSizeValid, isVideoImageValid, isVideoSupportValid, - isVideoFilterValid + isVideoFilterValid, + isPasswordValid, + isValidPasswordProtectedPrivacy } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index a92fd22d6..e2f34fe16 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 780 +const LAST_MIGRATION_VERSION = 785 // --------------------------------------------------------------------------- @@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = { VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], VIDEO_COMMENTS: [ 'createdAt' ], + VIDEO_PASSWORDS: [ 'createdAt' ], + VIDEO_RATES: [ 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], @@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = { REASON: { min: 1, max: 5000 }, // Length ERROR_MESSAGE: { min: 1, max: 5000 }, // Length PROGRESS: { min: 0, max: 100 } // Value + }, + VIDEO_PASSWORD: { + LENGTH: { min: 2, max: 100 } } } @@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = { [VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.PRIVATE]: 'Private', - [VideoPrivacy.INTERNAL]: 'Internal' + [VideoPrivacy.INTERNAL]: 'Internal', + [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected' } const VIDEO_STATES: { [ id in VideoState ]: string } = { diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 14dd8c379..9e926c26c 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -56,6 +56,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag' import { VideoViewModel } from '../models/view/video-view' import { CONFIG } from './config' +import { VideoPasswordModel } from '@server/models/video/video-password' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) { VideoJobInfoModel, VideoChannelSyncModel, UserRegistrationModel, + VideoPasswordModel, RunnerRegistrationTokenModel, RunnerModel, RunnerJobModel diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts new file mode 100644 index 000000000..1d85f4489 --- /dev/null +++ b/server/initializers/migrations/0785-video-password-protection.ts @@ -0,0 +1,31 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + const query = ` + CREATE TABLE IF NOT EXISTS "videoPassword" ( + "id" SERIAL, + "password" VARCHAR(255) NOT NULL, + "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY ("id") + ); + ` + + await utils.sequelize.query(query, { transaction : utils.transaction }) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 18b16bee1..be6df1792 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity' import { getBiggestActorImage } from './actor-image' import { Hooks } from './plugins/hooks' import { ServerConfigManager } from './server-config-manager' +import { isVideoInPrivateDirectory } from './video-privacy' type Tags = { ogType: string @@ -106,7 +107,7 @@ class ClientHtml { ]) // Let Angular application handle errors - if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { + if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) { res.status(HttpStatusCode.NOT_FOUND_404) return html } diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts index df67dc953..0ac667ba3 100644 --- a/server/lib/video-pre-import.ts +++ b/server/lib/video-pre-import.ts @@ -30,6 +30,7 @@ import { import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' import { getLocalVideoActivityPubUrl } from './activitypub/url' import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' +import { VideoPasswordModel } from '@server/models/video/video-password' class YoutubeDlImportError extends Error { code: YoutubeDlImportError.CODE @@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: { tags: string[] videoImportAttributes: FilteredModelAttributes user: MUser + videoPasswords?: string[] }): Promise { - const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters + const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters const videoImport = await sequelizeTypescript.transaction(async t => { const sequelizeOptions = { transaction: t } @@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: { if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) + if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + await VideoPasswordModel.addPasswords(videoPasswords, video.id, t) + } + await autoBlacklistVideoIfNeeded({ video: videoCreated, user, @@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: { state: VideoImportState.PENDING, userId: user.id, videoChannelSyncId: channelSync?.id - } + }, + videoPasswords: importDataOverride.videoPasswords }) // Get video subtitles diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts index 41f9d62b3..39430ef1e 100644 --- a/server/lib/video-privacy.ts +++ b/server/lib/video-privacy.ts @@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' import { VideoPrivacy, VideoStorage } from '@shared/models' import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' +const validPrivacySet = new Set([ + VideoPrivacy.PRIVATE, + VideoPrivacy.INTERNAL, + VideoPrivacy.PASSWORD_PROTECTED +]) + function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { video.publishedAt = new Date() @@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { video.privacy = newPrivacy } -function isVideoInPrivateDirectory (privacy: VideoPrivacy) { - return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL +function isVideoInPrivateDirectory (privacy) { + return validPrivacySet.has(privacy) } function isVideoInPublicDirectory (privacy: VideoPrivacy) { diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts index 660533528..e28e55cf7 100644 --- a/server/lib/video-tokens-manager.ts +++ b/server/lib/video-tokens-manager.ts @@ -12,26 +12,34 @@ class VideoTokensManager { private static instance: VideoTokensManager - private readonly lruCache = new LRUCache({ + private readonly lruCache = new LRUCache({ max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, ttl: LRU_CACHE.VIDEO_TOKENS.TTL }) private constructor () {} - create (options: { + createForAuthUser (options: { user: MUserAccountUrl videoUUID: string }) { - const token = buildUUID() - - const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) + const { token, expires } = this.generateVideoToken() this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) return { token, expires } } + createForPasswordProtectedVideo (options: { + videoUUID: string + }) { + const { token, expires } = this.generateVideoToken() + + this.lruCache.set(token, pick(options, [ 'videoUUID' ])) + + return { token, expires } + } + hasToken (options: { token: string videoUUID: string @@ -54,6 +62,13 @@ class VideoTokensManager { static get Instance () { return this.instance || (this.instance = new this()) } + + private generateVideoToken () { + const token = buildUUID() + const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) + + return { token, expires } + } } // --------------------------------------------------------------------------- diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts index 0eefa2a8e..39a7b2998 100644 --- a/server/middlewares/auth.ts +++ b/server/middlewares/auth.ts @@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner' import { HttpStatusCode } from '../../shared/models/http/http-error-codes' import { logger } from '../helpers/logger' import { handleOAuthAuthenticate } from '../lib/auth/oauth' +import { ServerErrorCode } from '@shared/models' function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { handleOAuthAuthenticate(req, res) @@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) { .catch(err => logger.error('Cannot get access token.', { err })) } -function authenticatePromise (req: express.Request, res: express.Response) { +function authenticatePromise (options: { + req: express.Request + res: express.Response + errorMessage?: string + errorStatus?: HttpStatusCode + errorType?: ServerErrorCode +}) { + const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options return new Promise(resolve => { // Already authenticated? (or tried to) if (res.locals.oauth?.token.User) return resolve() if (res.locals.authenticated === false) { return res.fail({ - status: HttpStatusCode.UNAUTHORIZED_401, - message: 'Not authenticated' + status: errorStatus, + type: errorType, + message: errorMessage }) } diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts index de98cd442..e5cff2dda 100644 --- a/server/middlewares/validators/shared/index.ts +++ b/server/middlewares/validators/shared/index.ts @@ -10,4 +10,5 @@ export * from './video-comments' export * from './video-imports' export * from './video-ownerships' export * from './video-playlists' +export * from './video-passwords' export * from './videos' diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts new file mode 100644 index 000000000..efcc95dc4 --- /dev/null +++ b/server/middlewares/validators/shared/video-passwords.ts @@ -0,0 +1,80 @@ +import express from 'express' +import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models' +import { forceNumber } from '@shared/core-utils' +import { VideoPasswordModel } from '@server/models/video/video-password' +import { header } from 'express-validator' +import { getVideoWithAttributes } from '@server/helpers/video' + +function isValidVideoPasswordHeader () { + return header('x-peertube-video-password') + .optional() + .isString() +} + +function checkVideoIsPasswordProtected (res: express.Response) { + const video = getVideoWithAttributes(res) + if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Video is not password protected' + }) + return false + } + + return true +} + +async function doesVideoPasswordExist (idArg: number | string, res: express.Response) { + const video = getVideoWithAttributes(res) + const id = forceNumber(idArg) + const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id }) + + if (!videoPassword) { + res.fail({ + status: HttpStatusCode.NOT_FOUND_404, + message: 'Video password not found' + }) + return false + } + + res.locals.videoPassword = videoPassword + + return true +} + +async function isVideoPasswordDeletable (res: express.Response) { + const user = res.locals.oauth.token.User + const userAccount = user.Account + const video = res.locals.videoAll + + // Check if the user who did the request is able to delete the video passwords + if ( + user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator + video.VideoChannel.accountId !== userAccount.id // Not the video owner + ) { + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'Cannot remove passwords of another user\'s video' + }) + return false + } + + const passwordCount = await VideoPasswordModel.countByVideoId(video.id) + + if (passwordCount <= 1) { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Cannot delete the last password of the protected video' + }) + return false + } + + return true +} + +export { + isValidVideoPasswordHeader, + checkVideoIsPasswordProtected as isVideoPasswordProtected, + doesVideoPasswordExist, + isVideoPasswordDeletable +} diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index 0033a32ff..9a7497007 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts @@ -20,6 +20,8 @@ import { MVideoWithRights } from '@server/types/models' import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' +import { VideoPasswordModel } from '@server/models/video/video-password' +import { exists } from '@server/helpers/custom-validators/misc' async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined @@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: { }) { const { req, res, video, paramId } = options - if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { - return checkCanSeeAuthVideo(req, res, video) + if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) { + return checkCanSeeUserAuthVideo({ req, res, video }) + } + + if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + return checkCanSeePasswordProtectedVideo({ req, res, video }) } if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { @@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: { throw new Error('Unknown video privacy when checking video right ' + video.url) } -async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { +async function checkCanSeeUserAuthVideo (options: { + req: Request + res: Response + video: MVideoId | MVideoWithRights +}) { + const { req, res, video } = options + const fail = () => { res.fail({ status: HttpStatusCode.FORBIDDEN_403, @@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI return false } - await authenticatePromise(req, res) + await authenticatePromise({ req, res }) const user = res.locals.oauth?.token.User if (!user) return fail() - const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId - ? video as MVideoWithRights - : await VideoModel.loadFull(video.id) + const videoWithRights = await getVideoWithRights(video as MVideoWithRights) const privacy = videoWithRights.privacy @@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI return true } - const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id - if (videoWithRights.isBlacklisted()) { - if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true + if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true return fail() } if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { - if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true + if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true return fail() } @@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI return fail() } +async function checkCanSeePasswordProtectedVideo (options: { + req: Request + res: Response + video: MVideo +}) { + const { req, res, video } = options + + const videoWithRights = await getVideoWithRights(video as MVideoWithRights) + + const videoPassword = req.header('x-peertube-video-password') + + if (!exists(videoPassword)) { + const errorMessage = 'Please provide a password to access this password protected video' + const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD + + if (req.header('authorization')) { + await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType }) + const user = res.locals.oauth?.token.User + + if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true + } + + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + type: errorType, + message: errorMessage + }) + return false + } + + if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true + + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD, + message: 'Incorrect video password. Access to the video is denied.' + }) + + return false +} + +function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) { + const isOwnedByUser = video.VideoChannel.Account.userId === user.id + + return isOwnedByUser || user.hasRight(right) +} + +async function getVideoWithRights (video: MVideoWithRights): Promise { + return video.VideoChannel?.Account?.userId + ? video + : VideoModel.loadFull(video.id) +} + // --------------------------------------------------------------------------- async function checkCanAccessVideoStaticFiles (options: { @@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: { }) { const { video, req, res } = options - if (res.locals.oauth?.token.User) { + if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) { return checkCanSeeVideo(options) } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 959f663ac..07d6cba82 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) +export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS) export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts index 9c2d890ba..36a94080c 100644 --- a/server/middlewares/validators/static.ts +++ b/server/middlewares/validators/static.ts @@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video' import { VideoFileModel } from '@server/models/video/video-file' import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' import { HttpStatusCode } from '@shared/models' -import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' +import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared' type LRUValue = { allowed: boolean @@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache({ const ensureCanAccessVideoPrivateWebTorrentFiles = [ query('videoFileToken').optional().custom(exists), + isValidVideoPasswordHeader(), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [ .optional() .customSanitizer(isSafePeerTubeFilenameWithoutExtension), + isValidVideoPasswordHeader(), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU } function extractTokenOrDie (req: express.Request, res: express.Response) { - const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken + const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken if (!token) { return res.fail({ - message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', + message: 'Video password header, video file token query parameter and bearer token are all missing', // status: HttpStatusCode.FORBIDDEN_403 }) } diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts index d225dfe45..0c824c314 100644 --- a/server/middlewares/validators/videos/index.ts +++ b/server/middlewares/validators/videos/index.ts @@ -12,6 +12,8 @@ export * from './video-shares' export * from './video-source' export * from './video-stats' export * from './video-studio' +export * from './video-token' export * from './video-transcoding' export * from './videos' export * from './video-channel-sync' +export * from './video-passwords' diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts index 72b2febc3..077a58d2e 100644 --- a/server/middlewares/validators/videos/video-captions.ts +++ b/server/middlewares/validators/videos/video-captions.ts @@ -10,7 +10,8 @@ import { checkUserCanManageVideo, doesVideoCaptionExist, doesVideoExist, - isValidVideoIdParam + isValidVideoIdParam, + isValidVideoPasswordHeader } from '../shared' const addVideoCaptionValidator = [ @@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [ const listVideoCaptionsValidator = [ isValidVideoIdParam('videoId'), + isValidVideoPasswordHeader(), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index 133feb7bd..70689b02e 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts @@ -14,7 +14,8 @@ import { doesVideoCommentExist, doesVideoCommentThreadExist, doesVideoExist, - isValidVideoIdParam + isValidVideoIdParam, + isValidVideoPasswordHeader } from '../shared' const listVideoCommentsValidator = [ @@ -51,6 +52,7 @@ const listVideoCommentsValidator = [ const listVideoCommentThreadsValidator = [ isValidVideoIdParam('videoId'), + isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [ param('threadId') .custom(isIdValid), + isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [ body('text') .custom(isValidVideoCommentText), + isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [ isValidVideoIdParam('videoId'), param('commentId').custom(isIdValid), + isValidVideoPasswordHeader(), body('text').custom(isValidVideoCommentText), diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts index 72442aeb6..a1cb65b70 100644 --- a/server/middlewares/validators/videos/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts @@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models' import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' -import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' +import { + isValidPasswordProtectedPrivacy, + isVideoMagnetUriValid, + isVideoNameValid +} from '../../../helpers/custom-validators/videos' import { cleanUpReqFiles } from '../../../helpers/express-utils' import { logger } from '../../../helpers/logger' import { CONFIG } from '../../../initializers/config' @@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ .custom(isVideoNameValid).withMessage( `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` ), + body('videoPasswords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { const user = res.locals.oauth.token.User @@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) + if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { cleanUpReqFiles(req) diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index 2aff831a8..ec69a3011 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts @@ -17,7 +17,7 @@ import { VideoState } from '@shared/models' import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' -import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos' +import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos' import { cleanUpReqFiles } from '../../../helpers/express-utils' import { logger } from '../../../helpers/logger' import { CONFIG } from '../../../initializers/config' @@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ body('replaySettings.privacy') .optional() .customSanitizer(toIntOrNull) - .custom(isVideoPrivacyValid), + .custom(isVideoReplayPrivacyValid), body('permanentLive') .optional() @@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ .customSanitizer(toIntOrNull) .custom(isLiveLatencyModeValid), + body('videoPasswords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return cleanUpReqFiles(req) + if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) + if (CONFIG.LIVE.ENABLED !== true) { cleanUpReqFiles(req) @@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [ body('replaySettings.privacy') .optional() .customSanitizer(toIntOrNull) - .custom(isVideoPrivacyValid), + .custom(isVideoReplayPrivacyValid), body('latencyMode') .optional() diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts new file mode 100644 index 000000000..200e496f6 --- /dev/null +++ b/server/middlewares/validators/videos/video-passwords.ts @@ -0,0 +1,77 @@ +import express from 'express' +import { + areValidationErrors, + doesVideoExist, + isVideoPasswordProtected, + isValidVideoIdParam, + doesVideoPasswordExist, + isVideoPasswordDeletable, + checkUserCanManageVideo +} from '../shared' +import { body, param } from 'express-validator' +import { isIdValid } from '@server/helpers/custom-validators/misc' +import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos' +import { UserRight } from '@shared/models' + +const listVideoPasswordValidator = [ + isValidVideoIdParam('videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await doesVideoExist(req.params.videoId, res)) return + if (!isVideoPasswordProtected(res)) return + + // Check if the user who did the request is able to access video password list + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return + + return next() + } +] + +const updateVideoPasswordListValidator = [ + body('passwords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await doesVideoExist(req.params.videoId, res)) return + if (!isValidPasswordProtectedPrivacy(req, res)) return + + // Check if the user who did the request is able to update video passwords + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + + return next() + } +] + +const removeVideoPasswordValidator = [ + isValidVideoIdParam('videoId'), + + param('passwordId') + .custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await doesVideoExist(req.params.videoId, res)) return + if (!isVideoPasswordProtected(res)) return + if (!await doesVideoPasswordExist(req.params.passwordId, res)) return + if (!await isVideoPasswordDeletable(res)) return + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + listVideoPasswordValidator, + updateVideoPasswordListValidator, + removeVideoPasswordValidator +} diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index c631a16f8..95a5ba63a 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => { } if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { - await authenticatePromise(req, res) + await authenticatePromise({ req, res }) const user = res.locals.oauth ? res.locals.oauth.token.User : null diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts index 275634d5b..c837b047b 100644 --- a/server/middlewares/validators/videos/video-rates.ts +++ b/server/middlewares/validators/videos/video-rates.ts @@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc' import { isRatingValid } from '../../../helpers/custom-validators/video-rates' import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' import { AccountVideoRateModel } from '../../../models/account/account-video-rate' -import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' +import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared' const videoUpdateRateValidator = [ isValidVideoIdParam('id'), body('rating') .custom(isVideoRatingTypeValid), + isValidVideoPasswordHeader(), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts new file mode 100644 index 000000000..d4253e21d --- /dev/null +++ b/server/middlewares/validators/videos/video-token.ts @@ -0,0 +1,24 @@ +import express from 'express' +import { VideoPrivacy } from '../../../../shared/models/videos' +import { HttpStatusCode } from '@shared/models' +import { exists } from '@server/helpers/custom-validators/misc' + +const videoFileTokenValidator = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + const video = res.locals.onlyVideo + if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) { + return res.fail({ + status: HttpStatusCode.UNAUTHORIZED_401, + message: 'Not authenticated' + }) + } + + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + videoFileTokenValidator +} diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 794e1d4f1..7f1f39b11 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../ import { areVideoTagsValid, isScheduleVideoUpdatePrivacyValid, + isValidPasswordProtectedPrivacy, isVideoCategoryValid, isVideoDescriptionValid, isVideoFileMimeTypeValid, @@ -55,7 +56,8 @@ import { doesVideoChannelOfAccountExist, doesVideoExist, doesVideoFileOfVideoExist, - isValidVideoIdParam + isValidVideoIdParam, + isValidVideoPasswordHeader } from '../shared' const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ @@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ body('channelId') .customSanitizer(toIntOrNull) .custom(isIdValid), + body('videoPasswords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return cleanUpReqFiles(req) @@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ return cleanUpReqFiles(req) } + if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) + try { if (!videoFile.duration) await addDurationToVideo(videoFile) } catch (err) { @@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ body('channelId') .customSanitizer(toIntOrNull) .custom(isIdValid), + body('videoPasswords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), header('x-upload-content-length') .isNumeric() @@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ const files = { videofile: [ videoFileMetadata ] } if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() + if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup() + // multer required unsetting the Content-Type, now we can set it for node-uploadx req.headers['content-type'] = 'application/json; charset=utf-8' // place previewfile in metadata so that uploadx saves it in .META @@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ .optional() .customSanitizer(toIntOrNull) .custom(isIdValid), + body('videoPasswords') + .optional() + .isArray() + .withMessage('Video passwords should be an array.'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return cleanUpReqFiles(req) if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) + if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) + const video = getVideoWithAttributes(res) if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { return res.fail({ message: 'Cannot update privacy of a live that has already started' }) @@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | return [ isValidVideoIdParam('id'), + isValidVideoPasswordHeader(), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return if (!await doesVideoExist(req.params.id, res, fetchType)) return diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts new file mode 100644 index 000000000..648366c3b --- /dev/null +++ b/server/models/video/video-password.ts @@ -0,0 +1,137 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { VideoModel } from './video' +import { AttributesOnly } from '@shared/typescript-utils' +import { ResultList, VideoPassword } from '@shared/models' +import { getSort, throwIfNotValid } from '../shared' +import { FindOptions, Transaction } from 'sequelize' +import { MVideoPassword } from '@server/types/models' +import { isPasswordValid } from '@server/helpers/custom-validators/videos' +import { pick } from '@shared/core-utils' + +@DefaultScope(() => ({ + include: [ + { + model: VideoModel.unscoped(), + required: true + } + ] +})) +@Table({ + tableName: 'videoPassword', + indexes: [ + { + fields: [ 'videoId', 'password' ], + unique: true + } + ] +}) +export class VideoPasswordModel extends Model>> { + + @AllowNull(false) + @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword')) + @Column + password: string + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'cascade' + }) + Video: VideoModel + + static async countByVideoId (videoId: number, t?: Transaction) { + const query: FindOptions = { + where: { + videoId + }, + transaction: t + } + + return VideoPasswordModel.count(query) + } + + static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise { + const { id, videoId, t } = options + const query: FindOptions = { + where: { + id, + videoId + }, + transaction: t + } + + return VideoPasswordModel.findOne(query) + } + + static async listPasswords (options: { + start: number + count: number + sort: string + videoId: number + }): Promise> { + const { start, count, sort, videoId } = options + + const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({ + where: { videoId }, + order: getSort(sort), + offset: start, + limit: count + }) + + return { total, data } + } + + static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise { + for (const password of passwords) { + await VideoPasswordModel.create({ + password, + videoId + }, { transaction }) + } + } + + static async deleteAllPasswords (videoId: number, transaction?: Transaction) { + await VideoPasswordModel.destroy({ + where: { videoId }, + transaction + }) + } + + static async deletePassword (passwordId: number, transaction?: Transaction) { + await VideoPasswordModel.destroy({ + where: { id: passwordId }, + transaction + }) + } + + static async isACorrectPassword (options: { + videoId: number + password: string + }) { + const query = { + where: pick(options, [ 'videoId', 'password' ]) + } + return VideoPasswordModel.findOne(query) + } + + toFormattedJSON (): VideoPassword { + return { + id: this.id, + password: this.password, + videoId: this.videoId, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } +} diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts index b832f9768..61ae6b9fe 100644 --- a/server/models/video/video-playlist-element.ts +++ b/server/models/video/video-playlist-element.ts @@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model>> { }) VideoCaptions: VideoCaptionModel[] + @HasMany(() => VideoPasswordModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + VideoPasswords: VideoPasswordModel[] + @HasOne(() => VideoJobInfoModel, { foreignKey: { name: 'videoId', @@ -1918,7 +1928,7 @@ export class VideoModel extends Model>> { // --------------------------------------------------------------------------- - requiresAuth (options: { + requiresUserAuth (options: { urlParamId: string checkBlacklist: boolean }) { @@ -1936,11 +1946,11 @@ export class VideoModel extends Model>> { if (checkBlacklist && this.VideoBlacklist) return true - if (this.privacy !== VideoPrivacy.PUBLIC) { - throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) + if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) { + return false } - return false + throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) } hasPrivateStaticPath () { diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts index 2dc735c23..406a96824 100644 --- a/server/tests/api/check-params/live.ts +++ b/server/tests/api/check-params/live.ts @@ -143,7 +143,7 @@ describe('Test video lives API validator', function () { }) it('Should fail with a bad privacy for replay settings', async function () { - const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } } + const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } } await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) @@ -472,7 +472,7 @@ describe('Test video lives API validator', function () { }) it('Should fail with a bad privacy for replay settings', async function () { - const fields = { saveReplay: true, replaySettings: { privacy: 5 } } + const fields = { saveReplay: true, replaySettings: { privacy: 999 } } await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) }) diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts new file mode 100644 index 000000000..4e936b5d2 --- /dev/null +++ b/server/tests/api/check-params/video-passwords.ts @@ -0,0 +1,609 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { + FIXTURE_URLS, + checkBadCountPagination, + checkBadSortPagination, + checkBadStartPagination, + checkUploadVideoParam +} from '@server/tests/shared' +import { root } from '@shared/core-utils' +import { + HttpStatusCode, + PeerTubeProblemDocument, + ServerErrorCode, + VideoCreateResult, + VideoPrivacy +} from '@shared/models' +import { + cleanupTests, + createSingleServer, + makePostBodyRequest, + PeerTubeServer, + setAccessTokensToServers +} from '@shared/server-commands' +import { expect } from 'chai' +import { join } from 'path' + +describe('Test video passwords validator', function () { + let path: string + let server: PeerTubeServer + let userAccessToken = '' + let video: VideoCreateResult + let channelId: number + let publicVideo: VideoCreateResult + let commentId: number + // --------------------------------------------------------------- + + before(async function () { + this.timeout(50000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + await server.config.updateCustomSubConfig({ + newConfig: { + live: { + enabled: true, + latencySetting: { + enabled: false + }, + allowReplay: false + }, + import: { + videos: { + http:{ + enabled: true + } + } + } + } + }) + + userAccessToken = await server.users.generateUserAndToken('user1') + + { + const body = await server.users.getMyInfo() + channelId = body.videoChannels[0].id + } + + { + video = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'password1', 'password2' ] + }) + } + path = '/api/v1/videos/' + }) + + async function checkVideoPasswordOptions (options: { + server: PeerTubeServer + token: string + videoPasswords: string[] + expectedStatus: HttpStatusCode + mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live' + }) { + const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options + const attaches = { + fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm') + } + const baseCorrectParams = { + name: 'my super name', + category: 5, + licence: 1, + language: 'pt', + nsfw: false, + commentsEnabled: true, + downloadEnabled: true, + waitTranscoding: true, + description: 'my super description', + support: 'my super support text', + tags: [ 'tag1', 'tag2' ], + privacy: VideoPrivacy.PASSWORD_PROTECTED, + channelId, + originallyPublishedAt: new Date().toISOString() + } + if (mode === 'uploadLegacy') { + const fields = { ...baseCorrectParams, videoPasswords } + return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy') + } + + if (mode === 'uploadResumable') { + const fields = { ...baseCorrectParams, videoPasswords } + return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable') + } + + if (mode === 'import') { + const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords } + return server.imports.importVideo({ attributes, expectedStatus }) + } + + if (mode === 'updateVideo') { + const attributes = { ...baseCorrectParams, videoPasswords } + return server.videos.update({ token, expectedStatus, id: video.id, attributes }) + } + + if (mode === 'updatePasswords') { + return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords }) + } + + if (mode === 'live') { + const fields = { ...baseCorrectParams, videoPasswords } + + return server.live.create({ fields, expectedStatus }) + } + } + + function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') { + + it('Should fail with a password protected privacy without providing a password', async function () { + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords: undefined, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and an empty password list', async function () { + const videoPasswords = [] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and a too short password', async function () { + const videoPasswords = [ 'p' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and a too long password', async function () { + const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and an empty password', async function () { + const videoPasswords = [ '' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + it('Should fail with a password protected privacy and duplicated passwords', async function () { + const videoPasswords = [ 'password', 'password' ] + + await checkVideoPasswordOptions({ + server, + token: server.accessToken, + videoPasswords, + expectedStatus: HttpStatusCode.BAD_REQUEST_400, + mode + }) + }) + + if (mode === 'updatePasswords') { + it('Should fail for an unauthenticated user', async function () { + const videoPasswords = [ 'password' ] + await checkVideoPasswordOptions({ + server, + token: null, + videoPasswords, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + mode + }) + }) + + it('Should fail for an unauthorized user', async function () { + const videoPasswords = [ 'password' ] + await checkVideoPasswordOptions({ + server, + token: userAccessToken, + videoPasswords, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode + }) + }) + } + + it('Should succeed with a password protected privacy and correct passwords', async function () { + const videoPasswords = [ 'password1', 'password2' ] + const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo' + ? HttpStatusCode.NO_CONTENT_204 + : HttpStatusCode.OK_200 + + await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode }) + }) + } + + describe('When adding or updating a video', function () { + describe('Resumable upload', function () { + validateVideoPasswordList('uploadResumable') + }) + + describe('Legacy upload', function () { + validateVideoPasswordList('uploadLegacy') + }) + + describe('When importing a video', function () { + validateVideoPasswordList('import') + }) + + describe('When updating a video', function () { + validateVideoPasswordList('updateVideo') + }) + + describe('When updating the password list of a video', function () { + validateVideoPasswordList('updatePasswords') + }) + + describe('When creating a live', function () { + validateVideoPasswordList('live') + }) + }) + + async function checkVideoAccessOptions (options: { + server: PeerTubeServer + token?: string + videoPassword?: string + expectedStatus: HttpStatusCode + mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token' + }) { + const { server, token = null, videoPassword, expectedStatus, mode } = options + + if (mode === 'get') { + return server.videos.get({ id: video.id, expectedStatus }) + } + + if (mode === 'getWithToken') { + return server.videos.getWithToken({ + id: video.id, + token, + expectedStatus + }) + } + + if (mode === 'getWithPassword') { + return server.videos.getWithPassword({ + id: video.id, + token, + expectedStatus, + password: videoPassword + }) + } + + if (mode === 'rate') { + return server.videos.rate({ + id: video.id, + token, + expectedStatus, + rating: 'like', + videoPassword + }) + } + + if (mode === 'createThread') { + const fields = { text: 'super comment' } + const headers = videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + const body = await makePostBodyRequest({ + url: server.url, + path: path + video.uuid + '/comment-threads', + token, + fields, + headers, + expectedStatus + }) + return JSON.parse(body.text) + } + + if (mode === 'replyThread') { + const fields = { text: 'super reply' } + const headers = videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + return makePostBodyRequest({ + url: server.url, + path: path + video.uuid + '/comments/' + commentId, + token, + fields, + headers, + expectedStatus + }) + } + if (mode === 'listThreads') { + return server.comments.listThreads({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + + if (mode === 'listCaptions') { + return server.captions.list({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + + if (mode === 'token') { + return server.videoToken.create({ + videoId: video.id, + token, + expectedStatus, + videoPassword + }) + } + } + + function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') { + const serverCode = mode === 'providePassword' + ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD + : ServerErrorCode.INCORRECT_VIDEO_PASSWORD + + const message = mode === 'providePassword' + ? 'Please provide a password to access this password protected video' + : 'Incorrect video password. Access to the video is denied.' + + if (!error.code) { + error = JSON.parse(error.text) + } + + expect(error.code).to.equal(serverCode) + expect(error.detail).to.equal(message) + expect(error.error).to.equal(message) + + expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) + } + + function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') { + const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode) + let tokens: string[] + if (!requiresUserAuth) { + it('Should fail without providing a password for an unlogged user', async function () { + const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'providePassword') + }) + } + + it('Should fail without providing a password for an unauthorised user', async function () { + const tmp = mode === 'get' ? 'getWithToken' : mode + + const body = await checkVideoAccessOptions({ + server, + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'providePassword') + }) + + it('Should fail if a wrong password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + tokens = [ userAccessToken, server.accessToken ] + + if (!requiresUserAuth) tokens.push(null) + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: 'toto', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should fail if an empty password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: '', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should fail if an inccorect password containing the correct password is entered', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + + for (const token of tokens) { + const body = await checkVideoAccessOptions({ + server, + token, + videoPassword: 'password11', + expectedStatus: HttpStatusCode.FORBIDDEN_403, + mode: tmp + }) + const error = body as unknown as PeerTubeProblemDocument + + checkVideoError(error, 'incorrectPassword') + } + }) + + it('Should succeed without providing a password for an authorised user', async function () { + const tmp = mode === 'get' ? 'getWithToken' : mode + const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 + + const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp }) + + if (mode === 'createThread') commentId = body.comment.id + }) + + it('Should succeed using correct passwords', async function () { + const tmp = mode === 'get' ? 'getWithPassword' : mode + const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 + + for (const token of tokens) { + await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp }) + await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp }) + } + }) + } + + describe('When accessing password protected video', function () { + + describe('For getting a password protected video', function () { + validateVideoAccess('get') + }) + + describe('For rating a video', function () { + validateVideoAccess('rate') + }) + + describe('For creating a thread', function () { + validateVideoAccess('createThread') + }) + + describe('For replying to a thread', function () { + validateVideoAccess('replyThread') + }) + + describe('For listing threads', function () { + validateVideoAccess('listThreads') + }) + + describe('For getting captions', function () { + validateVideoAccess('listCaptions') + }) + + describe('For creating video file token', function () { + validateVideoAccess('token') + }) + }) + + describe('When listing passwords', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken) + }) + + it('Should fail for unauthenticated user', async function () { + await server.videoPasswords.list({ + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401, + videoId: video.id + }) + }) + + it('Should fail for unauthorized user', async function () { + await server.videoPasswords.list({ + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + videoId: video.id + }) + }) + + it('Should succeed with the correct parameters', async function () { + await server.videoPasswords.list({ + token: server.accessToken, + expectedStatus: HttpStatusCode.OK_200, + videoId: video.id + }) + }) + }) + + describe('When deleting a password', async function () { + const passwords = (await server.videoPasswords.list({ videoId: video.id })).data + + it('Should fail with wrong password id', async function () { + await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should fail for unauthenticated user', async function () { + await server.videoPasswords.remove({ + id: passwords[0].id, + token: null, + videoId: video.id, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail for unauthorized user', async function () { + await server.videoPasswords.remove({ + id: passwords[0].id, + token: userAccessToken, + videoId: video.id, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail for non password protected video', async function () { + publicVideo = await server.videos.quickUpload({ name: 'public video' }) + await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail for password not linked to correct video', async function () { + const video2 = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'password1', 'password2' ] + }) + await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should succeed with correct parameter', async function () { + await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) + }) + + it('Should fail for last password of a video', async function () { + await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts index 7acb9d580..7cb3e84a2 100644 --- a/server/tests/api/check-params/video-token.ts +++ b/server/tests/api/check-params/video-token.ts @@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ describe('Test video tokens', function () { let server: PeerTubeServer - let videoId: string + let privateVideoId: string + let passwordProtectedVideoId: string let userToken: string + const videoPassword = 'password' + // --------------------------------------------------------------- before(async function () { @@ -15,27 +18,50 @@ describe('Test video tokens', function () { server = await createSingleServer(1) await setAccessTokensToServers([ server ]) - - const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) - videoId = uuid - + { + const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE }) + privateVideoId = uuid + } + { + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + passwordProtectedVideoId = uuid + } userToken = await server.users.generateUserAndToken('user1') }) - it('Should not generate tokens for unauthenticated user', async function () { - await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + it('Should not generate tokens on private video for unauthenticated user', async function () { + await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) }) it('Should not generate tokens of unknown video', async function () { await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) }) + it('Should not generate tokens with incorrect password', async function () { + await server.videoToken.create({ + videoId: passwordProtectedVideoId, + token: null, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + videoPassword: 'incorrectPassword' + }) + }) + it('Should not generate tokens of a non owned video', async function () { - await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) }) it('Should generate token', async function () { - await server.videoToken.create({ videoId }) + await server.videoToken.create({ videoId: privateVideoId }) + }) + + it('Should generate token on password protected video', async function () { + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null }) + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken }) + await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword }) }) after(async function () { diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts index af9d681b2..2a7c3381d 100644 --- a/server/tests/api/object-storage/video-static-file-privacy.ts +++ b/server/tests/api/object-storage/video-static-file-privacy.ts @@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () { describe('VOD', function () { let privateVideoUUID: string let publicVideoUUID: string + let passwordProtectedVideoUUID: string let userPrivateVideoUUID: string + const correctPassword = 'my super password' + const correctPasswordHeader = { 'x-peertube-video-password': correctPassword } + const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' } + // --------------------------------------------------------------------------- async function getSampleFileUrls (videoId: string) { @@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () { await checkPrivateVODFiles(privateVideoUUID) }) + it('Should upload a password protected video and have appropriate object storage ACL', async function () { + this.timeout(120000) + + { + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedVideoUUID = uuid + } + await waitJobs([ server ]) + + await checkPrivateVODFiles(passwordProtectedVideoUUID) + }) + it('Should upload a public video and have appropriate object storage ACL', async function () { this.timeout(120000) @@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () { await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) }) + it('Should not get files without appropriate password or appropriate OAuth token', async function () { + this.timeout(60000) + + const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) + + await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: webTorrentFile, + token: null, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url: webTorrentFile, + token: null, + headers: correctPasswordHeader, + expectedStatus: HttpStatusCode.OK_200 + }) + + await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: hlsFile, + token: null, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url: hlsFile, + token: null, + headers: correctPasswordHeader, + expectedStatus: HttpStatusCode.OK_200 + }) + }) + it('Should not get HLS file of another video', async function () { this.timeout(60000) @@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () { await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) }) - it('Should correctly check OAuth or video file token', async function () { + it('Should correctly check OAuth, video file token of private video', async function () { this.timeout(60000) const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) @@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () { await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + } + }) + + it('Should correctly check OAuth, video file token or video password of password protected video', async function () { + this.timeout(60000) + + const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) + const goodVideoFileToken = await server.videoToken.getVideoFileToken({ + videoId: passwordProtectedVideoUUID, + videoPassword: correctPassword + }) + + const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) + + for (const url of [ hlsFile, webTorrentFile ]) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ + url, + headers: incorrectPasswordHeader, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 }) } }) @@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () { let permanentLiveId: string let permanentLive: LiveVideo + let passwordProtectedLiveId: string + let passwordProtectedLive: LiveVideo + + const correctPassword = 'my super password' + let unrelatedFileToken: string // --------------------------------------------------------------------------- - async function checkLiveFiles (live: LiveVideo, liveId: string) { + async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) { const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) await server.live.waitUntilPublished({ videoId: liveId }) - const video = await server.videos.getWithToken({ id: liveId }) - const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + const video = videoPassword + ? await server.videos.getWithPassword({ id: liveId, password: videoPassword }) + : await server.videos.getWithToken({ id: liveId }) + + const fileToken = videoPassword + ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword }) + : await server.videoToken.getVideoFileToken({ videoId: video.uuid }) const hls = video.streamingPlaylists[0] @@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () { await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) - + if (videoPassword) { + await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) + } await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + if (videoPassword) { + await makeRawRequest({ + url, + headers: { 'x-peertube-video-password': 'incorrectPassword' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } } await stopFfmpeg(ffmpegCommand) @@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () { permanentLiveId = video.uuid permanentLive = live } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: false, + permanentLive: false, + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedLiveId = video.uuid + passwordProtectedLive = live + } }) it('Should create a private normal live and have a private static path', async function () { @@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () { await checkLiveFiles(permanentLive, permanentLiveId) }) + it('Should create a password protected live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword) + }) + it('Should reinject video file token in permanent live', async function () { this.timeout(240000) diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts new file mode 100644 index 000000000..e01a93a4d --- /dev/null +++ b/server/tests/api/videos/video-passwords.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + VideoPasswordsCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@shared/server-commands' +import { VideoPrivacy } from '@shared/models' + +describe('Test video passwords', function () { + let server: PeerTubeServer + let videoUUID: string + + let userAccessTokenServer1: string + + let videoPasswords: string[] = [] + let command: VideoPasswordsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + for (let i = 0; i < 10; i++) { + videoPasswords.push(`password ${i + 1}`) + } + const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } }) + videoUUID = uuid + + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + userAccessTokenServer1 = await server.users.generateUserAndToken('user1') + await setDefaultChannelAvatar(server, 'user1_channel') + await setDefaultAccountAvatar(server, userAccessTokenServer1) + + command = server.videoPasswords + }) + + it('Should list video passwords', async function () { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(10) + }) + + it('Should filter passwords on this video', async function () { + const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('password 4') + expect(body.data[1].password).to.equal('password 5') + }) + + it('Should update password for this video', async function () { + videoPasswords = [ 'my super new password 1', 'my super new password 2' ] + + await command.updateAll({ videoId: videoUUID, passwords: videoPasswords }) + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('my super new password 2') + expect(body.data[1].password).to.equal('my super new password 1') + }) + + it('Should delete one password', async function () { + { + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + await command.remove({ id: body.data[0].id, videoId: videoUUID }) + } + { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts index d9c5bdf16..9277b49f4 100644 --- a/server/tests/api/videos/video-playlists.ts +++ b/server/tests/api/videos/video-playlists.ts @@ -474,7 +474,7 @@ describe('Test video playlists', function () { await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) }) - it('Should get unlisted plyaylist using uuid or shortUUID', async function () { + it('Should get unlisted playlist using uuid or shortUUID', async function () { await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) }) @@ -686,7 +686,7 @@ describe('Test video playlists', function () { await waitJobs(servers) }) - it('Should update the element type if the video is private', async function () { + it('Should update the element type if the video is private/password protected', async function () { this.timeout(20000) const name = 'video 89' @@ -702,6 +702,19 @@ describe('Test video playlists', function () { await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) } + { + await servers[0].videos.update({ + id: video1, + attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } + }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + { await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) await waitJobs(servers) diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts index 542848533..ec4c697db 100644 --- a/server/tests/api/videos/video-static-file-privacy.ts +++ b/server/tests/api/videos/video-static-file-privacy.ts @@ -90,7 +90,7 @@ describe('Test video static file privacy', function () { } } - it('Should upload a private/internal video and have a private static path', async function () { + it('Should upload a private/internal/password protected video and have a private static path', async function () { this.timeout(120000) for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { @@ -99,6 +99,15 @@ describe('Test video static file privacy', function () { await checkPrivateFiles(uuid) } + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'my super password' ] + }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) }) it('Should upload a public video and update it as private/internal to have a private static path', async function () { @@ -185,8 +194,9 @@ describe('Test video static file privacy', function () { expectedStatus: HttpStatusCode token: string videoFileToken: string + videoPassword?: string }) { - const { id, expectedStatus, token, videoFileToken } = options + const { id, expectedStatus, token, videoFileToken, videoPassword } = options const video = await server.videos.getWithToken({ id }) @@ -196,6 +206,12 @@ describe('Test video static file privacy', function () { await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: file.fileUrl, headers, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus }) + } } const hls = video.streamingPlaylists[0] @@ -204,6 +220,12 @@ describe('Test video static file privacy', function () { await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus }) + } } before(async function () { @@ -216,13 +238,53 @@ describe('Test video static file privacy', function () { it('Should not be able to access a private video files without OAuth token and file token', async function () { this.timeout(120000) - const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) await waitJobs([ server ]) await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) }) - it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { + it('Should not be able to access password protected video files without OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: null, + videoFileToken: null, + videoPassword: null + }) + }) + + it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken, + videoPassword: 'incorrectPassword' + }) + }) + + it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () { this.timeout(120000) const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) @@ -247,6 +309,23 @@ describe('Test video static file privacy', function () { await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) }) + it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + + const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword }) + }) + it('Should reinject video file token', async function () { this.timeout(120000) @@ -294,13 +373,20 @@ describe('Test video static file privacy', function () { let permanentLiveId: string let permanentLive: LiveVideo + let passwordProtectedLiveId: string + let passwordProtectedLive: LiveVideo + + const correctPassword = 'my super password' + let unrelatedFileToken: string - async function checkLiveFiles (live: LiveVideo, liveId: string) { + async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) { + const { live, liveId, videoPassword } = options const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) await server.live.waitUntilPublished({ videoId: liveId }) const video = await server.videos.getWithToken({ id: liveId }) + const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) const hls = video.streamingPlaylists[0] @@ -314,6 +400,16 @@ describe('Test video static file privacy', function () { await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + if (videoPassword) { + await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url, + headers: { 'x-peertube-video-password': 'incorrectPassword' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + } await stopFfmpeg(ffmpegCommand) @@ -381,18 +477,35 @@ describe('Test video static file privacy', function () { permanentLiveId = video.uuid permanentLive = live } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: false, + permanentLive: false, + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedLiveId = video.uuid + passwordProtectedLive = live + } }) it('Should create a private normal live and have a private static path', async function () { this.timeout(240000) - await checkLiveFiles(normalLive, normalLiveId) + await checkLiveFiles({ live: normalLive, liveId: normalLiveId }) }) it('Should create a private permanent live and have a private static path', async function () { this.timeout(240000) - await checkLiveFiles(permanentLive, permanentLiveId) + await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId }) + }) + + it('Should create a password protected live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword }) }) it('Should reinject video file token on permanent live', async function () { diff --git a/server/tests/client.ts b/server/tests/client.ts index e84251561..68f3a1d14 100644 --- a/server/tests/client.ts +++ b/server/tests/client.ts @@ -56,6 +56,7 @@ describe('Test a client controllers', function () { let privateVideoId: string let internalVideoId: string let unlistedVideoId: string + let passwordProtectedVideoId: string let playlistIds: (string | number)[] = [] @@ -92,7 +93,12 @@ describe('Test a client controllers', function () { { ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); - ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })) + ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })); + ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({ + name: 'password protected', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'password' ] + })) } // Playlist @@ -502,9 +508,9 @@ describe('Test a client controllers', function () { } }) - it('Should not display internal/private video', async function () { + it('Should not display internal/private/password protected video', async function () { for (const basePath of watchVideoBasePaths) { - for (const id of [ privateVideoId, internalVideoId ]) { + for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) { const res = await makeGetRequest({ url: servers[0].url, path: basePath + id, @@ -514,6 +520,7 @@ describe('Test a client controllers', function () { expect(res.text).to.not.contain('internal') expect(res.text).to.not.contain('private') + expect(res.text).to.not.contain('password protected') } } }) diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts index 8433c873e..83a85be58 100644 --- a/server/tests/feeds/feeds.ts +++ b/server/tests/feeds/feeds.ts @@ -99,6 +99,13 @@ describe('Test syndication feeds', () => { await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) } + { + const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } + const { id } = await servers[0].videos.upload({ attributes }) + + await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' }) + } + await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) await waitJobs([ ...servers, serverHLSOnly ]) @@ -445,7 +452,7 @@ describe('Test syndication feeds', () => { describe('Video comments feed', function () { - it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { + it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () { for (const server of servers) { const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 510b9f94e..9c1be9bd1 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -18,6 +18,7 @@ import { MVideoId, MVideoImmutable, MVideoLiveFormattable, + MVideoPassword, MVideoPlaylistFull, MVideoPlaylistFullSummary } from '@server/types/models' @@ -165,6 +166,8 @@ declare module 'express' { videoCommentFull?: MCommentOwnerVideoReply videoCommentThread?: MComment + videoPassword?: MVideoPassword + follow?: MActorFollowActorsDefault subscription?: MActorFollowActorsDefaultSubscription diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts index 6e45fcc79..0ac032290 100644 --- a/server/types/models/video/index.ts +++ b/server/types/models/video/index.ts @@ -16,6 +16,7 @@ export * from './video-import' export * from './video-live-replay-setting' export * from './video-live-session' export * from './video-live' +export * from './video-password' export * from './video-playlist' export * from './video-playlist-element' export * from './video-rate' diff --git a/server/types/models/video/video-password.ts b/server/types/models/video/video-password.ts new file mode 100644 index 000000000..313cc3e0c --- /dev/null +++ b/server/types/models/video/video-password.ts @@ -0,0 +1,3 @@ +import { VideoPasswordModel } from '@server/models/video/video-password' + +export type MVideoPassword = Omit diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts index 58ae7baad..8021e56bb 100644 --- a/server/types/models/video/video.ts +++ b/server/types/models/video/video.ts @@ -32,7 +32,7 @@ type Use = PickWith export type MVideo = Omit + 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'> // ############################################################################ @@ -46,7 +46,7 @@ export type MVideoFeed = Pick // ############################################################################ -// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists +// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords // "With" to not confuse with the VideoFile model export type MVideoWithFile = diff --git a/shared/core-utils/videos/common.ts b/shared/core-utils/videos/common.ts index 2c6efdb7f..0431edaaf 100644 --- a/shared/core-utils/videos/common.ts +++ b/shared/core-utils/videos/common.ts @@ -3,7 +3,7 @@ import { VideoPrivacy } from '../../models/videos/video-privacy.enum' import { VideoDetails } from '../../models/videos/video.model' function getAllPrivacies () { - return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ] + return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ] } function getAllFiles (video: Partial>) { diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts index 2b093380c..77d1e1d3f 100644 --- a/shared/models/server/server-error-code.enum.ts +++ b/shared/models/server/server-error-code.enum.ts @@ -49,7 +49,10 @@ export const enum ServerErrorCode { RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state', - UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token' + UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token', + + VIDEO_REQUIRES_PASSWORD = 'video_requires_password', + INCORRECT_VIDEO_PASSWORD = 'incorrect_video_password' } /** diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 4c1790228..80be1854b 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -39,3 +39,4 @@ export * from './video-update.model' export * from './video-view.model' export * from './video.model' export * from './video-create-result.model' +export * from './video-password.model' diff --git a/shared/models/videos/video-create.model.ts b/shared/models/videos/video-create.model.ts index 732d508d1..7a34b5afe 100644 --- a/shared/models/videos/video-create.model.ts +++ b/shared/models/videos/video-create.model.ts @@ -18,6 +18,7 @@ export interface VideoCreate { privacy: VideoPrivacy scheduleUpdate?: VideoScheduleUpdate originallyPublishedAt?: Date | string + videoPasswords?: string[] thumbnailfile?: Blob | string previewfile?: Blob | string diff --git a/shared/models/videos/video-password.model.ts b/shared/models/videos/video-password.model.ts new file mode 100644 index 000000000..c0280b9b9 --- /dev/null +++ b/shared/models/videos/video-password.model.ts @@ -0,0 +1,7 @@ +export interface VideoPassword { + id: number + password: string + videoId: number + createdAt: Date | string + updatedAt: Date | string +} diff --git a/shared/models/videos/video-privacy.enum.ts b/shared/models/videos/video-privacy.enum.ts index 39fd0529f..12e1d196f 100644 --- a/shared/models/videos/video-privacy.enum.ts +++ b/shared/models/videos/video-privacy.enum.ts @@ -2,5 +2,6 @@ export const enum VideoPrivacy { PUBLIC = 1, UNLISTED = 2, PRIVATE = 3, - INTERNAL = 4 + INTERNAL = 4, + PASSWORD_PROTECTED = 5 } diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts index 86653b959..43537b5af 100644 --- a/shared/models/videos/video-update.model.ts +++ b/shared/models/videos/video-update.model.ts @@ -19,6 +19,7 @@ export interface VideoUpdate { previewfile?: Blob scheduleUpdate?: VideoScheduleUpdate originallyPublishedAt?: Date | string + videoPasswords?: string[] pluginData?: any } diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts index e3f1817f1..8227017eb 100644 --- a/shared/server-commands/requests/requests.ts +++ b/shared/server-commands/requests/requests.ts @@ -29,6 +29,7 @@ function makeRawRequest (options: { range?: string query?: { [ id: string ]: string } method?: 'GET' | 'POST' + headers?: { [ name: string ]: string } }) { const { host, protocol, pathname } = new URL(options.url) @@ -37,7 +38,7 @@ function makeRawRequest (options: { path: pathname, contentType: undefined, - ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ]) + ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ]) } if (options.method === 'POST') { @@ -132,6 +133,7 @@ function makePutBodyRequest (options: { token?: string fields: { [ fieldName: string ]: any } expectedStatus?: HttpStatusCode + headers?: { [name: string]: string } }) { const req = request(options.url).put(options.path) .send(options.fields) diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index 70f7a3ee2..0911e22b0 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -32,6 +32,7 @@ import { HistoryCommand, ImportsCommand, LiveCommand, + VideoPasswordsCommand, PlaylistsCommand, ServicesCommand, StreamingPlaylistsCommand, @@ -146,6 +147,7 @@ export class PeerTubeServer { twoFactor?: TwoFactorCommand videoToken?: VideoTokenCommand registrations?: RegistrationsCommand + videoPasswords?: VideoPasswordsCommand runners?: RunnersCommand runnerRegistrationTokens?: RunnerRegistrationTokensCommand @@ -437,5 +439,6 @@ export class PeerTubeServer { this.runners = new RunnersCommand(this) this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) this.runnerJobs = new RunnerJobsCommand(this) + this.videoPasswords = new VideoPasswordsCommand(this) } } diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts index ca4ffada9..463acc26b 100644 --- a/shared/server-commands/shared/abstract-command.ts +++ b/shared/server-commands/shared/abstract-command.ts @@ -101,25 +101,29 @@ abstract class AbstractCommand { protected putBodyRequest (options: InternalCommonCommandOptions & { fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } }) { - const { fields } = options + const { fields, headers } = options return makePutBodyRequest({ ...this.buildCommonRequestOptions(options), - fields + fields, + headers }) } protected postBodyRequest (options: InternalCommonCommandOptions & { fields?: { [ fieldName: string ]: any } + headers?: { [name: string]: string } }) { - const { fields } = options + const { fields, headers } = options return makePostBodyRequest({ ...this.buildCommonRequestOptions(options), - fields + fields, + headers }) } @@ -206,6 +210,12 @@ abstract class AbstractCommand { return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus } + + protected buildVideoPasswordHeader (videoPassword: string) { + return videoPassword !== undefined && videoPassword !== null + ? { 'x-peertube-video-password': videoPassword } + : undefined + } } export { diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts index 62bf9c5e6..a26fcb57d 100644 --- a/shared/server-commands/videos/captions-command.ts +++ b/shared/server-commands/videos/captions-command.ts @@ -34,14 +34,16 @@ export class CaptionsCommand extends AbstractCommand { list (options: OverrideCommandOptions & { videoId: string | number + videoPassword?: string }) { - const { videoId } = options + const { videoId, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/captions' return this.getRequestBody>({ ...options, path, + headers: this.buildVideoPasswordHeader(videoPassword), implicitToken: false, defaultExpectedStatus: HttpStatusCode.OK_200 }) diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts index 154ec0c24..0dab1b66a 100644 --- a/shared/server-commands/videos/comments-command.ts +++ b/shared/server-commands/videos/comments-command.ts @@ -36,11 +36,12 @@ export class CommentsCommand extends AbstractCommand { listThreads (options: OverrideCommandOptions & { videoId: number | string + videoPassword?: string start?: number count?: number sort?: string }) { - const { start, count, sort, videoId } = options + const { start, count, sort, videoId, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comment-threads' return this.getRequestBody({ @@ -48,6 +49,7 @@ export class CommentsCommand extends AbstractCommand { path, query: { start, count, sort }, + headers: this.buildVideoPasswordHeader(videoPassword), implicitToken: false, defaultExpectedStatus: HttpStatusCode.OK_200 }) @@ -72,8 +74,9 @@ export class CommentsCommand extends AbstractCommand { async createThread (options: OverrideCommandOptions & { videoId: number | string text: string + videoPassword?: string }) { - const { videoId, text } = options + const { videoId, text, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comment-threads' const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ @@ -81,6 +84,7 @@ export class CommentsCommand extends AbstractCommand { path, fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 })) @@ -95,8 +99,9 @@ export class CommentsCommand extends AbstractCommand { videoId: number | string toCommentId: number text: string + videoPassword?: string }) { - const { videoId, toCommentId, text } = options + const { videoId, toCommentId, text, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ @@ -104,6 +109,7 @@ export class CommentsCommand extends AbstractCommand { path, fields: { text }, + headers: this.buildVideoPasswordHeader(videoPassword), implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 })) diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index c17f6ef20..da36b5b6b 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts @@ -17,3 +17,4 @@ export * from './video-studio-command' export * from './video-token-command' export * from './views-command' export * from './videos-command' +export * from './video-passwords-command' diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts index 44d625970..6006d9fe9 100644 --- a/shared/server-commands/videos/live-command.ts +++ b/shared/server-commands/videos/live-command.ts @@ -120,8 +120,13 @@ export class LiveCommand extends AbstractCommand { saveReplay: boolean permanentLive: boolean privacy?: VideoPrivacy + videoPasswords?: string[] }) { - const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC } = options + const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options + + const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED + ? { privacy: VideoPrivacy.PRIVATE } + : { privacy } const { uuid } = await this.create({ ...options, @@ -130,9 +135,10 @@ export class LiveCommand extends AbstractCommand { name: 'live', permanentLive, saveReplay, - replaySettings: { privacy }, + replaySettings, channelId: this.server.store.channel.id, - privacy + privacy, + videoPasswords } }) diff --git a/shared/server-commands/videos/video-passwords-command.ts b/shared/server-commands/videos/video-passwords-command.ts new file mode 100644 index 000000000..bf10335b4 --- /dev/null +++ b/shared/server-commands/videos/video-passwords-command.ts @@ -0,0 +1,55 @@ +import { HttpStatusCode, ResultList, VideoPassword } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' +export class VideoPasswordsCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + videoId: number | string + start?: number + count?: number + sort?: string + }) { + const { start, count, sort, videoId } = options + const path = '/api/v1/videos/' + videoId + '/passwords' + + return this.getRequestBody>({ + ...options, + + path, + query: { start, count, sort }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + updateAll (options: OverrideCommandOptions & { + videoId: number | string + passwords: string[] + }) { + const { videoId, passwords } = options + const path = `/api/v1/videos/${videoId}/passwords` + + return this.putBodyRequest({ + ...options, + path, + fields: { passwords }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove (options: OverrideCommandOptions & { + id: number + videoId: number | string + }) { + const { id, videoId } = options + const path = `/api/v1/videos/${videoId}/passwords/${id}` + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts index 0531bee65..c4ed29a8c 100644 --- a/shared/server-commands/videos/video-token-command.ts +++ b/shared/server-commands/videos/video-token-command.ts @@ -8,12 +8,14 @@ export class VideoTokenCommand extends AbstractCommand { create (options: OverrideCommandOptions & { videoId: number | string + videoPassword?: string }) { - const { videoId } = options + const { videoId, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/token' return unwrapBody(this.postBodyRequest({ ...options, + headers: this.buildVideoPasswordHeader(videoPassword), path, implicitToken: true, @@ -23,6 +25,7 @@ export class VideoTokenCommand extends AbstractCommand { async getVideoFileToken (options: OverrideCommandOptions & { videoId: number | string + videoPassword?: string }) { const { files } = await this.create(options) diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts index b5df9c325..93ca623e1 100644 --- a/shared/server-commands/videos/videos-command.ts +++ b/shared/server-commands/videos/videos-command.ts @@ -111,8 +111,9 @@ export class VideosCommand extends AbstractCommand { rate (options: OverrideCommandOptions & { id: number | string rating: UserVideoRateType + videoPassword?: string }) { - const { id, rating } = options + const { id, rating, videoPassword } = options const path = '/api/v1/videos/' + id + '/rate' return this.putBodyRequest({ @@ -120,6 +121,7 @@ export class VideosCommand extends AbstractCommand { path, fields: { rating }, + headers: this.buildVideoPasswordHeader(videoPassword), implicitToken: true, defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 }) @@ -151,6 +153,23 @@ export class VideosCommand extends AbstractCommand { }) } + getWithPassword (options: OverrideCommandOptions & { + id: number | string + password?: string + }) { + const path = '/api/v1/videos/' + options.id + + return this.getRequestBody({ + ...options, + headers:{ + 'x-peertube-video-password': options.password + }, + path, + implicitToken: false, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + getSource (options: OverrideCommandOptions & { id: number | string }) { @@ -608,11 +627,13 @@ export class VideosCommand extends AbstractCommand { nsfw?: boolean privacy?: VideoPrivacy fixture?: string + videoPasswords?: string[] }) { const attributes: VideoEdit = { name: options.name } if (options.nsfw) attributes.nsfw = options.nsfw if (options.privacy) attributes.privacy = options.privacy if (options.fixture) attributes.fixture = options.fixture + if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords return this.upload({ ...options, attributes }) } diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index cd50e86a6..ff94f802b 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -300,6 +300,8 @@ tags: - name: Runner Registration Token description: | Manage runner registration token + - name: Video Passwords + description: Operation on video passwords x-tagGroups: - name: Static endpoints @@ -337,6 +339,7 @@ x-tagGroups: - Video Transcoding - Live Videos - Channels Sync + - Video Passwords - name: Search tags: - Search @@ -2359,6 +2362,7 @@ paths: - OAuth2: [] parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -2578,6 +2582,8 @@ paths: format: date-time scheduleUpdate: $ref: '#/components/schemas/VideoScheduledUpdate' + videoPasswords: + $ref: '#/components/schemas/AddVideoPasswords' encoding: thumbnailfile: contentType: image/jpeg @@ -2590,6 +2596,7 @@ paths: - Video parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -2597,6 +2604,8 @@ paths: application/json: schema: $ref: '#/components/schemas/VideoDetails' + '403': + description: provide a correct password to access this password protected video delete: summary: Delete a video operationId: delVideo @@ -2618,6 +2627,7 @@ paths: - Video parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -3267,6 +3277,7 @@ paths: - Live Videos parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -3665,6 +3676,7 @@ paths: - Video Captions parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -3728,6 +3740,70 @@ paths: '404': description: video or language or caption for that language not found + /api/v1/videos/{id}/passwords: + get: + summary: List video passwords + security: + - OAuth2: + - user + tags: + - Video Passwords + parameters: + - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/start' + - $ref: '#/components/parameters/count' + - $ref: '#/components/parameters/sort' + responses: + '204': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/VideoPasswordList' + '400': + description: video is not password protected + put: + summary: Update video passwords + security: + - OAuth2: + - user + tags: + - Video Passwords + parameters: + - $ref: '#/components/parameters/idOrUUID' + requestBody: + content: + application/json: + schema: + type: object + properties: + passwords: + $ref: '#/components/schemas/AddVideoPasswords' + responses: + '204': + description: successful operation + '400': + description: video is not password protected + + /api/v1/videos/{id}/passwords/{videoPasswordId}: + delete: + summary: Delete a video password + security: + - OAuth2: + - user + tags: + - Video Passwords + parameters: + - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordId' + responses: + '204': + description: successful operation + '403': + description: cannot delete the last password of the protected video + '400': + description: video is not password protected + /api/v1/video-channels: get: summary: List video channels @@ -4554,6 +4630,7 @@ paths: - $ref: '#/components/parameters/start' - $ref: '#/components/parameters/count' - $ref: '#/components/parameters/commentsSort' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -4600,6 +4677,7 @@ paths: parameters: - $ref: '#/components/parameters/idOrUUID' - $ref: '#/components/parameters/threadId' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -4618,6 +4696,7 @@ paths: parameters: - $ref: '#/components/parameters/idOrUUID' - $ref: '#/components/parameters/commentId' + - $ref: '#/components/parameters/videoPasswordHeader' responses: '200': description: successful operation @@ -4668,6 +4747,7 @@ paths: - Video Rates parameters: - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' requestBody: content: application/json: @@ -6525,7 +6605,20 @@ components: required: true schema: $ref: '#/components/schemas/UUIDv4' - + videoPasswordId: + name: videoPasswordId + in: path + required: true + description: The video password id + schema: + $ref: '#/components/schemas/id' + videoPasswordHeader: + name: x-peertube-video-password + description: Required on password protected video + in: header + required: false + schema: + type: string securitySchemes: OAuth2: description: | @@ -8228,6 +8321,8 @@ components: description: Video preview file type: string format: binary + videoPasswords: + $ref: '#/components/schemas/AddVideoPasswords' required: - channelId - name @@ -9616,6 +9711,29 @@ components: privatePayload: type: object + VideoPassword: + properties: + id: + $ref: '#/components/schemas/id' + password: + type: string + minLength: 2 + videoId: + $ref: '#/components/schemas/id' + VideoPasswordList: + properties: + total: + type: integer + example: 1 + data: + type: array + items: + $ref: '#/components/schemas/VideoPassword' + AddVideoPasswords: + type: array + items: + $ref: "#/components/schemas/VideoPassword/properties/password" + uniqueItems: true callbacks: searchIndex: 'https://search.example.org/api/v1/search/videos':