diff options
122 files changed, 2632 insertions, 252 deletions
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts index 4b9357fb7..195b265a1 100644 --- a/client/src/app/+admin/overview/videos/video-admin.service.ts +++ b/client/src/app/+admin/overview/videos/video-admin.service.ts | |||
@@ -151,7 +151,7 @@ export class VideoAdminService { | |||
151 | } | 151 | } |
152 | 152 | ||
153 | if (filters.excludePublic) { | 153 | if (filters.excludePublic) { |
154 | privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] | 154 | privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ] |
155 | 155 | ||
156 | filters.excludePublic = undefined | 156 | filters.excludePublic = undefined |
157 | } | 157 | } |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts index 97ffb6013..393c3ad6b 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts | |||
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit { | |||
30 | async disableTwoFactor () { | 30 | async disableTwoFactor () { |
31 | const message = $localize`Are you sure you want to disable two factor authentication of your account?` | 31 | const message = $localize`Are you sure you want to disable two factor authentication of your account?` |
32 | 32 | ||
33 | const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) | 33 | const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` }) |
34 | if (confirmed === false) return | 34 | if (confirmed === false) return |
35 | 35 | ||
36 | this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) | 36 | this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index b607dabe9..97b713874 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -120,7 +120,12 @@ | |||
120 | </div> | 120 | </div> |
121 | </div> | 121 | </div> |
122 | 122 | ||
123 | <div *ngIf="schedulePublicationEnabled" class="form-group"> | 123 | <div *ngIf="passwordProtectionSelected" class="form-group"> |
124 | <label i18n for="videoPassword">Password</label> | ||
125 | <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text> | ||
126 | </div> | ||
127 | |||
128 | <div *ngIf="schedulePublicationSelected" class="form-group"> | ||
124 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> | 129 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> |
125 | <p-calendar | 130 | <p-calendar |
126 | id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" | 131 | id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" |
@@ -287,7 +292,7 @@ | |||
287 | <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> | 292 | <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> |
288 | <label i18n for="replayPrivacy">Privacy of the new replay</label> | 293 | <label i18n for="replayPrivacy">Privacy of the new replay</label> |
289 | <my-select-options | 294 | <my-select-options |
290 | labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" | 295 | labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy" |
291 | ></my-select-options> | 296 | ></my-select-options> |
292 | </div> | 297 | </div> |
293 | 298 | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 8ed54ce6b..5e5df8db7 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts | |||
@@ -14,6 +14,7 @@ import { | |||
14 | VIDEO_LICENCE_VALIDATOR, | 14 | VIDEO_LICENCE_VALIDATOR, |
15 | VIDEO_NAME_VALIDATOR, | 15 | VIDEO_NAME_VALIDATOR, |
16 | VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, | 16 | VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, |
17 | VIDEO_PASSWORD_VALIDATOR, | ||
17 | VIDEO_PRIVACY_VALIDATOR, | 18 | VIDEO_PRIVACY_VALIDATOR, |
18 | VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, | 19 | VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, |
19 | VIDEO_SUPPORT_VALIDATOR, | 20 | VIDEO_SUPPORT_VALIDATOR, |
@@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
79 | // So that it can be accessed in the template | 80 | // So that it can be accessed in the template |
80 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | 81 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY |
81 | 82 | ||
82 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | 83 | videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = [] |
84 | replayPrivacies: VideoConstant<VideoPrivacy> [] = [] | ||
83 | videoCategories: VideoConstant<number>[] = [] | 85 | videoCategories: VideoConstant<number>[] = [] |
84 | videoLicences: VideoConstant<number>[] = [] | 86 | videoLicences: VideoConstant<number>[] = [] |
85 | videoLanguages: VideoLanguages[] = [] | 87 | videoLanguages: VideoLanguages[] = [] |
@@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
103 | 105 | ||
104 | pluginDataFormGroup: FormGroup | 106 | pluginDataFormGroup: FormGroup |
105 | 107 | ||
106 | schedulePublicationEnabled = false | 108 | schedulePublicationSelected = false |
109 | passwordProtectionSelected = false | ||
107 | 110 | ||
108 | calendarLocale: any = {} | 111 | calendarLocale: any = {} |
109 | minScheduledDate = new Date() | 112 | minScheduledDate = new Date() |
@@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
148 | const obj: { [ id: string ]: BuildFormValidator } = { | 151 | const obj: { [ id: string ]: BuildFormValidator } = { |
149 | name: VIDEO_NAME_VALIDATOR, | 152 | name: VIDEO_NAME_VALIDATOR, |
150 | privacy: VIDEO_PRIVACY_VALIDATOR, | 153 | privacy: VIDEO_PRIVACY_VALIDATOR, |
154 | videoPassword: VIDEO_PASSWORD_VALIDATOR, | ||
151 | channelId: VIDEO_CHANNEL_VALIDATOR, | 155 | channelId: VIDEO_CHANNEL_VALIDATOR, |
152 | nsfw: null, | 156 | nsfw: null, |
153 | commentsEnabled: null, | 157 | commentsEnabled: null, |
@@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
222 | 226 | ||
223 | this.serverService.getVideoPrivacies() | 227 | this.serverService.getVideoPrivacies() |
224 | .subscribe(privacies => { | 228 | .subscribe(privacies => { |
225 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies | 229 | const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies |
230 | this.videoPrivacies = videoPrivacies | ||
231 | this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED) | ||
226 | 232 | ||
227 | // Can't schedule publication if private privacy is not available (could be deleted by a plugin) | 233 | // Can't schedule publication if private privacy is not available (could be deleted by a plugin) |
228 | const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) | 234 | const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) |
@@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
410 | .subscribe( | 416 | .subscribe( |
411 | newPrivacyId => { | 417 | newPrivacyId => { |
412 | 418 | ||
413 | this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY | 419 | this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY |
414 | 420 | ||
415 | // Value changed | 421 | // Value changed |
416 | const scheduleControl = this.form.get('schedulePublicationAt') | 422 | const scheduleControl = this.form.get('schedulePublicationAt') |
417 | const waitTranscodingControl = this.form.get('waitTranscoding') | 423 | const waitTranscodingControl = this.form.get('waitTranscoding') |
418 | 424 | ||
419 | if (this.schedulePublicationEnabled) { | 425 | if (this.schedulePublicationSelected) { |
420 | scheduleControl.setValidators([ Validators.required ]) | 426 | scheduleControl.setValidators([ Validators.required ]) |
421 | 427 | ||
422 | waitTranscodingControl.disable() | 428 | waitTranscodingControl.disable() |
@@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
437 | 443 | ||
438 | this.firstPatchDone = true | 444 | this.firstPatchDone = true |
439 | 445 | ||
446 | this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED | ||
447 | const videoPasswordControl = this.form.get('videoPassword') | ||
448 | |||
449 | if (this.passwordProtectionSelected) { | ||
450 | videoPasswordControl.setValidators([ Validators.required ]) | ||
451 | } else { | ||
452 | videoPasswordControl.clearValidators() | ||
453 | } | ||
454 | videoPasswordControl.updateValueAndValidity() | ||
455 | |||
440 | } | 456 | } |
441 | ) | 457 | ) |
442 | } | 458 | } |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index ad71162b8..629d95c08 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
49 | this.buildForm({}) | 49 | this.buildForm({}) |
50 | 50 | ||
51 | const { videoData } = this.route.snapshot.data | 51 | const { videoData } = this.route.snapshot.data |
52 | const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData | 52 | const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData |
53 | 53 | ||
54 | this.videoDetails = video | 54 | this.videoDetails = video |
55 | this.videoEdit = new VideoEdit(this.videoDetails) | 55 | this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) |
56 | 56 | ||
57 | this.userVideoChannels = videoChannels | 57 | this.userVideoChannels = videoChannels |
58 | this.videoCaptions = videoCaptions | 58 | this.videoCaptions = videoCaptions |
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts index 6612d22de..2c99b36a8 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts | |||
@@ -4,8 +4,9 @@ import { Injectable } from '@angular/core' | |||
4 | import { ActivatedRouteSnapshot } from '@angular/router' | 4 | import { ActivatedRouteSnapshot } from '@angular/router' |
5 | import { AuthService } from '@app/core' | 5 | import { AuthService } from '@app/core' |
6 | import { listUserChannelsForSelect } from '@app/helpers' | 6 | import { listUserChannelsForSelect } from '@app/helpers' |
7 | import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { VideoPrivacy } from '@shared/models/videos' | ||
9 | 10 | ||
10 | @Injectable() | 11 | @Injectable() |
11 | export class VideoUpdateResolver { | 12 | export class VideoUpdateResolver { |
@@ -13,7 +14,8 @@ export class VideoUpdateResolver { | |||
13 | private videoService: VideoService, | 14 | private videoService: VideoService, |
14 | private liveVideoService: LiveVideoService, | 15 | private liveVideoService: LiveVideoService, |
15 | private authService: AuthService, | 16 | private authService: AuthService, |
16 | private videoCaptionService: VideoCaptionService | 17 | private videoCaptionService: VideoCaptionService, |
18 | private videoPasswordService: VideoPasswordService | ||
17 | ) { | 19 | ) { |
18 | } | 20 | } |
19 | 21 | ||
@@ -21,11 +23,11 @@ export class VideoUpdateResolver { | |||
21 | const uuid: string = route.params['uuid'] | 23 | const uuid: string = route.params['uuid'] |
22 | 24 | ||
23 | return this.videoService.getVideo({ videoId: uuid }) | 25 | return this.videoService.getVideo({ videoId: uuid }) |
24 | .pipe( | 26 | .pipe( |
25 | switchMap(video => forkJoin(this.buildVideoObservables(video))), | 27 | switchMap(video => forkJoin(this.buildVideoObservables(video))), |
26 | map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) => | 28 | map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => |
27 | ({ video, videoChannels, videoCaptions, videoSource, liveVideo })) | 29 | ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) |
28 | ) | 30 | ) |
29 | } | 31 | } |
30 | 32 | ||
31 | private buildVideoObservables (video: VideoDetails) { | 33 | private buildVideoObservables (video: VideoDetails) { |
@@ -46,6 +48,10 @@ export class VideoUpdateResolver { | |||
46 | 48 | ||
47 | video.isLive | 49 | video.isLive |
48 | ? this.liveVideoService.getVideoLive(video.id) | 50 | ? this.liveVideoService.getVideoLive(video.id) |
51 | : of(undefined), | ||
52 | |||
53 | video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
54 | ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid }) | ||
49 | : of(undefined) | 55 | : of(undefined) |
50 | ] | 56 | ] |
51 | } | 57 | } |
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html index cf32e371a..140a391e9 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html | |||
@@ -1,7 +1,7 @@ | |||
1 | <div class="video-actions-rates"> | 1 | <div class="video-actions-rates"> |
2 | <div class="video-actions justify-content-end"> | 2 | <div class="video-actions justify-content-end"> |
3 | <my-video-rate | 3 | <my-video-rate |
4 | [video]="video" [isUserLoggedIn]="isUserLoggedIn" | 4 | [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn" |
5 | (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" | 5 | (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" |
6 | ></my-video-rate> | 6 | ></my-video-rate> |
7 | 7 | ||
@@ -20,7 +20,7 @@ | |||
20 | 20 | ||
21 | <div | 21 | <div |
22 | class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" | 22 | class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" |
23 | *ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)" | 23 | *ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)" |
24 | [ngbTooltip]="tooltipSaveToPlaylist" | 24 | [ngbTooltip]="tooltipSaveToPlaylist" |
25 | placement="bottom auto" | 25 | placement="bottom auto" |
26 | > | 26 | > |
@@ -43,7 +43,7 @@ | |||
43 | <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> | 43 | <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> |
44 | </button> | 44 | </button> |
45 | 45 | ||
46 | <my-video-download #videoDownloadModal></my-video-download> | 46 | <my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download> |
47 | </ng-container> | 47 | </ng-container> |
48 | 48 | ||
49 | <ng-container *ngIf="isUserLoggedIn"> | 49 | <ng-container *ngIf="isUserLoggedIn"> |
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts index 51718827d..e6c0d4de1 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts | |||
@@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal' | |||
5 | import { SupportModalComponent } from '@app/shared/shared-support-modal' | 5 | import { SupportModalComponent } from '@app/shared/shared-support-modal' |
6 | import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' | 6 | import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' |
7 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | 7 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' |
8 | import { UserVideoRateType, VideoCaption } from '@shared/models/videos' | 8 | import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos' |
9 | 9 | ||
10 | @Component({ | 10 | @Component({ |
11 | selector: 'my-action-buttons', | 11 | selector: 'my-action-buttons', |
@@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges { | |||
18 | @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent | 18 | @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent |
19 | 19 | ||
20 | @Input() video: VideoDetails | 20 | @Input() video: VideoDetails |
21 | @Input() videoPassword: string | ||
21 | @Input() videoCaptions: VideoCaption[] | 22 | @Input() videoCaptions: VideoCaption[] |
22 | @Input() playlist: VideoPlaylist | 23 | @Input() playlist: VideoPlaylist |
23 | 24 | ||
24 | @Input() isUserLoggedIn: boolean | 25 | @Input() isUserLoggedIn: boolean |
26 | @Input() isUserOwner: boolean | ||
25 | 27 | ||
26 | @Input() currentTime: number | 28 | @Input() currentTime: number |
27 | @Input() currentPlaylistPosition: number | 29 | @Input() currentPlaylistPosition: number |
@@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges { | |||
92 | private setVideoLikesBarTooltipText () { | 94 | private setVideoLikesBarTooltipText () { |
93 | this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` | 95 | this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` |
94 | } | 96 | } |
97 | |||
98 | isVideoAddableToPlaylist () { | ||
99 | const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
100 | |||
101 | if (!this.isUserLoggedIn) return false | ||
102 | |||
103 | if (isPasswordProtected) return this.isUserOwner | ||
104 | |||
105 | return true | ||
106 | } | ||
95 | } | 107 | } |
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts index d0c138834..11966ce34 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts | |||
@@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models' | |||
12 | }) | 12 | }) |
13 | export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { | 13 | export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { |
14 | @Input() video: VideoDetails | 14 | @Input() video: VideoDetails |
15 | @Input() videoPassword: string | ||
15 | @Input() isUserLoggedIn: boolean | 16 | @Input() isUserLoggedIn: boolean |
16 | 17 | ||
17 | @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() | 18 | @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() |
@@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { | |||
103 | } | 104 | } |
104 | 105 | ||
105 | private setRating (nextRating: UserVideoRateType) { | 106 | private setRating (nextRating: UserVideoRateType) { |
106 | const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = { | 107 | const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = { |
107 | like: this.videoService.setVideoLike, | 108 | like: this.videoService.setVideoLike, |
108 | dislike: this.videoService.setVideoDislike, | 109 | dislike: this.videoService.setVideoDislike, |
109 | none: this.videoService.unsetVideoLike | 110 | none: this.videoService.unsetVideoLike |
110 | } | 111 | } |
111 | 112 | ||
112 | ratingMethods[nextRating].call(this.videoService, this.video.uuid) | 113 | ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword) |
113 | .subscribe({ | 114 | .subscribe({ |
114 | next: () => { | 115 | next: () => { |
115 | // Update the video like attribute | 116 | // Update the video like attribute |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts index 033097084..1d9e10d0a 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts | |||
@@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models' | |||
29 | export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { | 29 | export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { |
30 | @Input() user: User | 30 | @Input() user: User |
31 | @Input() video: Video | 31 | @Input() video: Video |
32 | @Input() videoPassword: string | ||
32 | @Input() parentComment?: VideoComment | 33 | @Input() parentComment?: VideoComment |
33 | @Input() parentComments?: VideoComment[] | 34 | @Input() parentComments?: VideoComment[] |
34 | @Input() focusOnInit = false | 35 | @Input() focusOnInit = false |
@@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges, | |||
176 | 177 | ||
177 | private addCommentReply (commentCreate: VideoCommentCreate) { | 178 | private addCommentReply (commentCreate: VideoCommentCreate) { |
178 | return this.videoCommentService | 179 | return this.videoCommentService |
179 | .addCommentReply(this.video.uuid, this.parentComment.id, commentCreate) | 180 | .addCommentReply({ |
181 | videoId: this.video.uuid, | ||
182 | inReplyToCommentId: this.parentComment.id, | ||
183 | comment: commentCreate, | ||
184 | videoPassword: this.videoPassword | ||
185 | }) | ||
180 | } | 186 | } |
181 | 187 | ||
182 | private addCommentThread (commentCreate: VideoCommentCreate) { | 188 | private addCommentThread (commentCreate: VideoCommentCreate) { |
183 | return this.videoCommentService | 189 | return this.videoCommentService |
184 | .addCommentThread(this.video.uuid, commentCreate) | 190 | .addCommentThread(this.video.uuid, commentCreate, this.videoPassword) |
185 | } | 191 | } |
186 | 192 | ||
187 | private initTextValue () { | 193 | private initTextValue () { |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html index 91bd8309c..80ea22a20 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html | |||
@@ -62,6 +62,7 @@ | |||
62 | *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" | 62 | *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" |
63 | [user]="user" | 63 | [user]="user" |
64 | [video]="video" | 64 | [video]="video" |
65 | [videoPassword]="videoPassword" | ||
65 | [parentComment]="comment" | 66 | [parentComment]="comment" |
66 | [parentComments]="newParentComments" | 67 | [parentComments]="newParentComments" |
67 | [focusOnInit]="true" | 68 | [focusOnInit]="true" |
@@ -75,6 +76,7 @@ | |||
75 | <my-video-comment | 76 | <my-video-comment |
76 | [comment]="commentChild.comment" | 77 | [comment]="commentChild.comment" |
77 | [video]="video" | 78 | [video]="video" |
79 | [videoPassword]="videoPassword" | ||
78 | [inReplyToCommentId]="inReplyToCommentId" | 80 | [inReplyToCommentId]="inReplyToCommentId" |
79 | [commentTree]="commentChild" | 81 | [commentTree]="commentChild" |
80 | [parentComments]="newParentComments" | 82 | [parentComments]="newParentComments" |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts index 191ec4a28..4c85df657 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts | |||
@@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { | |||
16 | @ViewChild('commentReportModal') commentReportModal: CommentReportComponent | 16 | @ViewChild('commentReportModal') commentReportModal: CommentReportComponent |
17 | 17 | ||
18 | @Input() video: Video | 18 | @Input() video: Video |
19 | @Input() videoPassword: string | ||
19 | @Input() comment: VideoComment | 20 | @Input() comment: VideoComment |
20 | @Input() parentComments: VideoComment[] = [] | 21 | @Input() parentComments: VideoComment[] = [] |
21 | @Input() commentTree: VideoCommentThreadTree | 22 | @Input() commentTree: VideoCommentThreadTree |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html index a003a10eb..0932d2b7f 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html | |||
@@ -20,6 +20,7 @@ | |||
20 | <ng-template [ngIf]="video.commentsEnabled === true"> | 20 | <ng-template [ngIf]="video.commentsEnabled === true"> |
21 | <my-video-comment-add | 21 | <my-video-comment-add |
22 | [video]="video" | 22 | [video]="video" |
23 | [videoPassword]="videoPassword" | ||
23 | [user]="user" | 24 | [user]="user" |
24 | (commentCreated)="onCommentThreadCreated($event)" | 25 | (commentCreated)="onCommentThreadCreated($event)" |
25 | [textValue]="commentThreadRedraftValue" | 26 | [textValue]="commentThreadRedraftValue" |
@@ -34,6 +35,7 @@ | |||
34 | *ngIf="highlightedThread" | 35 | *ngIf="highlightedThread" |
35 | [comment]="highlightedThread" | 36 | [comment]="highlightedThread" |
36 | [video]="video" | 37 | [video]="video" |
38 | [videoPassword]="videoPassword" | ||
37 | [inReplyToCommentId]="inReplyToCommentId" | 39 | [inReplyToCommentId]="inReplyToCommentId" |
38 | [commentTree]="threadComments[highlightedThread.id]" | 40 | [commentTree]="threadComments[highlightedThread.id]" |
39 | [highlightedComment]="true" | 41 | [highlightedComment]="true" |
@@ -53,6 +55,7 @@ | |||
53 | *ngIf="!highlightedThread || comment.id !== highlightedThread.id" | 55 | *ngIf="!highlightedThread || comment.id !== highlightedThread.id" |
54 | [comment]="comment" | 56 | [comment]="comment" |
55 | [video]="video" | 57 | [video]="video" |
58 | [videoPassword]="videoPassword" | ||
56 | [inReplyToCommentId]="inReplyToCommentId" | 59 | [inReplyToCommentId]="inReplyToCommentId" |
57 | [commentTree]="threadComments[comment.id]" | 60 | [commentTree]="threadComments[comment.id]" |
58 | [firstInThread]="i + 1 !== comments.length" | 61 | [firstInThread]="i + 1 !== comments.length" |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts index 96bdb28c9..848936f91 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts | |||
@@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models' | |||
15 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | 15 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { |
16 | @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef | 16 | @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef |
17 | @Input() video: VideoDetails | 17 | @Input() video: VideoDetails |
18 | @Input() videoPassword: string | ||
18 | @Input() user: User | 19 | @Input() user: User |
19 | 20 | ||
20 | @Output() timestampClicked = new EventEmitter<number>() | 21 | @Output() timestampClicked = new EventEmitter<number>() |
@@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
80 | 81 | ||
81 | const params = { | 82 | const params = { |
82 | videoId: this.video.uuid, | 83 | videoId: this.video.uuid, |
83 | threadId: commentId | 84 | threadId: commentId, |
85 | videoPassword: this.videoPassword | ||
84 | } | 86 | } |
85 | 87 | ||
86 | const obs = this.hooks.wrapObsFun( | 88 | const obs = this.hooks.wrapObsFun( |
@@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
119 | loadMoreThreads () { | 121 | loadMoreThreads () { |
120 | const params = { | 122 | const params = { |
121 | videoId: this.video.uuid, | 123 | videoId: this.video.uuid, |
124 | videoPassword: this.videoPassword, | ||
122 | componentPagination: this.componentPagination, | 125 | componentPagination: this.componentPagination, |
123 | sort: this.sort | 126 | sort: this.sort |
124 | } | 127 | } |
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html index 79b83811d..45e222743 100644 --- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html +++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html | |||
@@ -42,3 +42,7 @@ | |||
42 | <div class="blocked-label" i18n>This video is blocked.</div> | 42 | <div class="blocked-label" i18n>This video is blocked.</div> |
43 | {{ video.blacklistedReason }} | 43 | {{ video.blacklistedReason }} |
44 | </div> | 44 | </div> |
45 | |||
46 | <div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)"> | ||
47 | This video is password protected. | ||
48 | </div> | ||
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts index ba79fabc8..8781ead7e 100644 --- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts +++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { AuthUser } from '@app/core' | ||
2 | import { VideoDetails } from '@app/shared/shared-main' | 3 | import { VideoDetails } from '@app/shared/shared-main' |
3 | import { VideoState } from '@shared/models' | 4 | import { VideoPrivacy, VideoState } from '@shared/models' |
4 | 5 | ||
5 | @Component({ | 6 | @Component({ |
6 | selector: 'my-video-alert', | 7 | selector: 'my-video-alert', |
@@ -8,6 +9,7 @@ import { VideoState } from '@shared/models' | |||
8 | styleUrls: [ './video-alert.component.scss' ] | 9 | styleUrls: [ './video-alert.component.scss' ] |
9 | }) | 10 | }) |
10 | export class VideoAlertComponent { | 11 | export class VideoAlertComponent { |
12 | @Input() user: AuthUser | ||
11 | @Input() video: VideoDetails | 13 | @Input() video: VideoDetails |
12 | @Input() noPlaylistVideoFound: boolean | 14 | @Input() noPlaylistVideoFound: boolean |
13 | 15 | ||
@@ -46,4 +48,8 @@ export class VideoAlertComponent { | |||
46 | isLiveEnded () { | 48 | isLiveEnded () { |
47 | return this.video?.state.id === VideoState.LIVE_ENDED | 49 | return this.video?.state.id === VideoState.LIVE_ENDED |
48 | } | 50 | } |
51 | |||
52 | isVideoPasswordProtected () { | ||
53 | return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
54 | } | ||
49 | } | 55 | } |
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html index 461891779..80fd6e40f 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html | |||
@@ -19,7 +19,7 @@ | |||
19 | <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> | 19 | <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> |
20 | </div> | 20 | </div> |
21 | 21 | ||
22 | <my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert> | 22 | <my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert> |
23 | 23 | ||
24 | <!-- Video information --> | 24 | <!-- Video information --> |
25 | <div *ngIf="video" class="margin-content video-bottom"> | 25 | <div *ngIf="video" class="margin-content video-bottom"> |
@@ -51,8 +51,8 @@ | |||
51 | </div> | 51 | </div> |
52 | 52 | ||
53 | <my-action-buttons | 53 | <my-action-buttons |
54 | [video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist" | 54 | [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions" |
55 | [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" | 55 | [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" |
56 | ></my-action-buttons> | 56 | ></my-action-buttons> |
57 | </div> | 57 | </div> |
58 | </div> | 58 | </div> |
@@ -92,6 +92,7 @@ | |||
92 | <my-video-comments | 92 | <my-video-comments |
93 | class="border-top" | 93 | class="border-top" |
94 | [video]="video" | 94 | [video]="video" |
95 | [videoPassword]="videoPassword" | ||
95 | [user]="user" | 96 | [user]="user" |
96 | (timestampClicked)="handleTimestampClicked($event)" | 97 | (timestampClicked)="handleTimestampClicked($event)" |
97 | ></my-video-comments> | 98 | ></my-video-comments> |
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 19ad97d42..aba3ee086 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts | |||
@@ -25,7 +25,7 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | |||
25 | import { LiveVideoService } from '@app/shared/shared-video-live' | 25 | import { LiveVideoService } from '@app/shared/shared-video-live' |
26 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | 26 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' |
27 | import { logger } from '@root-helpers/logger' | 27 | import { logger } from '@root-helpers/logger' |
28 | import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' | 28 | import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video' |
29 | import { timeToInt } from '@shared/core-utils' | 29 | import { timeToInt } from '@shared/core-utils' |
30 | import { | 30 | import { |
31 | HTMLServerConfig, | 31 | HTMLServerConfig, |
@@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
68 | video: VideoDetails = null | 68 | video: VideoDetails = null |
69 | videoCaptions: VideoCaption[] = [] | 69 | videoCaptions: VideoCaption[] = [] |
70 | liveVideo: LiveVideo | 70 | liveVideo: LiveVideo |
71 | videoPassword: string | ||
71 | 72 | ||
72 | playlistPosition: number | 73 | playlistPosition: number |
73 | playlist: VideoPlaylist = null | 74 | playlist: VideoPlaylist = null |
@@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
191 | return this.authService.isLoggedIn() | 192 | return this.authService.isLoggedIn() |
192 | } | 193 | } |
193 | 194 | ||
195 | isUserOwner () { | ||
196 | return this.video.isLocal === true && this.video.account.name === this.user?.username | ||
197 | } | ||
198 | |||
194 | isVideoBlur (video: Video) { | 199 | isVideoBlur (video: Video) { |
195 | return video.isVideoNSFWForUser(this.user, this.serverConfig) | 200 | return video.isVideoNSFWForUser(this.user, this.serverConfig) |
196 | } | 201 | } |
@@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
243 | private loadVideo (options: { | 248 | private loadVideo (options: { |
244 | videoId: string | 249 | videoId: string |
245 | forceAutoplay: boolean | 250 | forceAutoplay: boolean |
251 | videoPassword?: string | ||
246 | }) { | 252 | }) { |
247 | const { videoId, forceAutoplay } = options | 253 | const { videoId, forceAutoplay, videoPassword } = options |
248 | 254 | ||
249 | if (this.isSameElement(this.video, videoId)) return | 255 | if (this.isSameElement(this.video, videoId)) return |
250 | 256 | ||
@@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
254 | 260 | ||
255 | const videoObs = this.hooks.wrapObsFun( | 261 | const videoObs = this.hooks.wrapObsFun( |
256 | this.videoService.getVideo.bind(this.videoService), | 262 | this.videoService.getVideo.bind(this.videoService), |
257 | { videoId }, | 263 | { videoId, videoPassword }, |
258 | 'video-watch', | 264 | 'video-watch', |
259 | 'filter:api.video-watch.video.get.params', | 265 | 'filter:api.video-watch.video.get.params', |
260 | 'filter:api.video-watch.video.get.result' | 266 | 'filter:api.video-watch.video.get.result' |
@@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
269 | }), | 275 | }), |
270 | 276 | ||
271 | switchMap(({ video, live }) => { | 277 | switchMap(({ video, live }) => { |
272 | if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) | 278 | if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined }) |
273 | 279 | ||
274 | return this.videoFileTokenService.getVideoFileToken(video.uuid) | 280 | return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword }) |
275 | .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) | 281 | .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) |
276 | }) | 282 | }) |
277 | ) | 283 | ) |
278 | 284 | ||
279 | forkJoin([ | 285 | forkJoin([ |
280 | videoAndLiveObs, | 286 | videoAndLiveObs, |
281 | this.videoCaptionService.listCaptions(videoId), | 287 | this.videoCaptionService.listCaptions(videoId, videoPassword), |
282 | this.userService.getAnonymousOrLoggedUser() | 288 | this.userService.getAnonymousOrLoggedUser() |
283 | ]).subscribe({ | 289 | ]).subscribe({ |
284 | next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { | 290 | next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { |
@@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
304 | live, | 310 | live, |
305 | videoCaptions: captionsResult.data, | 311 | videoCaptions: captionsResult.data, |
306 | videoFileToken, | 312 | videoFileToken, |
313 | videoPassword, | ||
307 | loggedInOrAnonymousUser, | 314 | loggedInOrAnonymousUser, |
308 | urlOptions, | 315 | urlOptions, |
309 | forceAutoplay | 316 | forceAutoplay |
310 | }).catch(err => this.handleGlobalError(err)) | 317 | }).catch(err => { |
318 | this.handleGlobalError(err) | ||
319 | }) | ||
311 | }, | 320 | }, |
321 | error: async err => { | ||
322 | if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) { | ||
323 | const { confirmed, password } = await this.handleVideoPasswordError(err) | ||
324 | |||
325 | if (confirmed === false) return this.location.back() | ||
312 | 326 | ||
313 | error: err => this.handleRequestError(err) | 327 | this.loadVideo({ ...options, videoPassword: password }) |
328 | } else { | ||
329 | this.handleRequestError(err) | ||
330 | } | ||
331 | } | ||
314 | }) | 332 | }) |
315 | } | 333 | } |
316 | 334 | ||
@@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
375 | this.notifier.error(errorMessage) | 393 | this.notifier.error(errorMessage) |
376 | } | 394 | } |
377 | 395 | ||
396 | private handleVideoPasswordError (err: any) { | ||
397 | let isIncorrectPassword: boolean | ||
398 | |||
399 | if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) { | ||
400 | isIncorrectPassword = false | ||
401 | } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) { | ||
402 | this.videoPassword = undefined | ||
403 | isIncorrectPassword = true | ||
404 | } | ||
405 | |||
406 | return this.confirmService.confirmWithPassword({ | ||
407 | message: $localize`You need a password to watch this video`, | ||
408 | title: $localize`This video is password protected`, | ||
409 | errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : '' | ||
410 | }) | ||
411 | } | ||
412 | |||
378 | private async onVideoFetched (options: { | 413 | private async onVideoFetched (options: { |
379 | video: VideoDetails | 414 | video: VideoDetails |
380 | live: LiveVideo | 415 | live: LiveVideo |
381 | videoCaptions: VideoCaption[] | 416 | videoCaptions: VideoCaption[] |
382 | videoFileToken: string | 417 | videoFileToken: string |
418 | videoPassword: string | ||
383 | 419 | ||
384 | urlOptions: URLOptions | 420 | urlOptions: URLOptions |
385 | loggedInOrAnonymousUser: User | 421 | loggedInOrAnonymousUser: User |
386 | forceAutoplay: boolean | 422 | forceAutoplay: boolean |
387 | }) { | 423 | }) { |
388 | const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options | 424 | const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options |
389 | 425 | ||
390 | this.subscribeToLiveEventsIfNeeded(this.video, video) | 426 | this.subscribeToLiveEventsIfNeeded(this.video, video) |
391 | 427 | ||
@@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
393 | this.videoCaptions = videoCaptions | 429 | this.videoCaptions = videoCaptions |
394 | this.liveVideo = live | 430 | this.liveVideo = live |
395 | this.videoFileToken = videoFileToken | 431 | this.videoFileToken = videoFileToken |
432 | this.videoPassword = videoPassword | ||
396 | 433 | ||
397 | // Re init attributes | 434 | // Re init attributes |
398 | this.playerPlaceholderImgSrc = undefined | 435 | this.playerPlaceholderImgSrc = undefined |
@@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
450 | videoCaptions: this.videoCaptions, | 487 | videoCaptions: this.videoCaptions, |
451 | liveVideo: this.liveVideo, | 488 | liveVideo: this.liveVideo, |
452 | videoFileToken: this.videoFileToken, | 489 | videoFileToken: this.videoFileToken, |
490 | videoPassword: this.videoPassword, | ||
453 | urlOptions, | 491 | urlOptions, |
454 | loggedInOrAnonymousUser, | 492 | loggedInOrAnonymousUser, |
455 | forceAutoplay, | 493 | forceAutoplay, |
@@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
600 | videoCaptions: VideoCaption[] | 638 | videoCaptions: VideoCaption[] |
601 | 639 | ||
602 | videoFileToken: string | 640 | videoFileToken: string |
641 | videoPassword: string | ||
603 | 642 | ||
604 | urlOptions: CustomizationOptions & { playerMode: PlayerMode } | 643 | urlOptions: CustomizationOptions & { playerMode: PlayerMode } |
605 | 644 | ||
@@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
607 | forceAutoplay: boolean | 646 | forceAutoplay: boolean |
608 | user?: AuthUser // Keep for plugins | 647 | user?: AuthUser // Keep for plugins |
609 | }) { | 648 | }) { |
610 | const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params | 649 | const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params |
611 | 650 | ||
612 | const getStartTime = () => { | 651 | const getStartTime = () => { |
613 | const byUrl = urlOptions.startTime !== undefined | 652 | const byUrl = urlOptions.startTime !== undefined |
@@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
689 | serverUrl: environment.originServerUrl || window.location.origin, | 728 | serverUrl: environment.originServerUrl || window.location.origin, |
690 | 729 | ||
691 | videoFileToken: () => videoFileToken, | 730 | videoFileToken: () => videoFileToken, |
692 | requiresAuth: videoRequiresAuth(video), | 731 | requiresUserAuth: videoRequiresUserAuth(video, videoPassword), |
732 | requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && | ||
733 | !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), | ||
734 | videoPassword: () => videoPassword, | ||
693 | 735 | ||
694 | videoCaptions: playerCaptions, | 736 | videoCaptions: playerCaptions, |
695 | 737 | ||
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts index 89a25f0a5..abe163aae 100644 --- a/client/src/app/core/confirm/confirm.service.ts +++ b/client/src/app/core/confirm/confirm.service.ts | |||
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core' | |||
4 | type ConfirmOptions = { | 4 | type ConfirmOptions = { |
5 | title: string | 5 | title: string |
6 | message: string | 6 | message: string |
7 | errorMessage?: string | ||
7 | } & ( | 8 | } & ( |
8 | { | 9 | { |
9 | type: 'confirm' | 10 | type: 'confirm' |
@@ -12,6 +13,7 @@ type ConfirmOptions = { | |||
12 | { | 13 | { |
13 | type: 'confirm-password' | 14 | type: 'confirm-password' |
14 | confirmButtonText?: string | 15 | confirmButtonText?: string |
16 | isIncorrectPassword?: boolean | ||
15 | } | | 17 | } | |
16 | { | 18 | { |
17 | type: 'confirm-expected-input' | 19 | type: 'confirm-expected-input' |
@@ -32,8 +34,14 @@ export class ConfirmService { | |||
32 | return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) | 34 | return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) |
33 | } | 35 | } |
34 | 36 | ||
35 | confirmWithPassword (message: string, title = '', confirmButtonText?: string) { | 37 | confirmWithPassword (options: { |
36 | this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) | 38 | message: string |
39 | title?: string | ||
40 | confirmButtonText?: string | ||
41 | errorMessage?: string | ||
42 | }) { | ||
43 | const { message, title = '', confirmButtonText, errorMessage } = options | ||
44 | this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage }) | ||
37 | 45 | ||
38 | const obs = this.confirmResponse.asObservable() | 46 | const obs = this.confirmResponse.asObservable() |
39 | .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) | 47 | .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) |
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html index 6584db3e6..33696d0a5 100644 --- a/client/src/app/modal/confirm.component.html +++ b/client/src/app/modal/confirm.component.html | |||
@@ -12,10 +12,12 @@ | |||
12 | <div *ngIf="inputLabel" class="form-group mt-3"> | 12 | <div *ngIf="inputLabel" class="form-group mt-3"> |
13 | <label for="confirmInput">{{ inputLabel }}</label> | 13 | <label for="confirmInput">{{ inputLabel }}</label> |
14 | 14 | ||
15 | <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> | 15 | <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" /> |
16 | 16 | ||
17 | <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text> | 17 | <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text> |
18 | </div> | 18 | </div> |
19 | |||
20 | <div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div> | ||
19 | </div> | 21 | </div> |
20 | 22 | ||
21 | <div class="modal-footer inputs"> | 23 | <div class="modal-footer inputs"> |
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts index 3bb8b9b21..43369befa 100644 --- a/client/src/app/modal/confirm.component.ts +++ b/client/src/app/modal/confirm.component.ts | |||
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit { | |||
21 | inputValue = '' | 21 | inputValue = '' |
22 | confirmButtonText = '' | 22 | confirmButtonText = '' |
23 | 23 | ||
24 | errorMessage = '' | ||
25 | |||
24 | isPasswordInput = false | 26 | isPasswordInput = false |
25 | 27 | ||
26 | private openedModal: NgbModalRef | 28 | private openedModal: NgbModalRef |
@@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit { | |||
42 | this.inputValue = '' | 44 | this.inputValue = '' |
43 | this.confirmButtonText = '' | 45 | this.confirmButtonText = '' |
44 | this.isPasswordInput = false | 46 | this.isPasswordInput = false |
47 | this.errorMessage = '' | ||
45 | 48 | ||
46 | const { type, title, message, confirmButtonText } = payload | 49 | const { type, title, message, confirmButtonText, errorMessage } = payload |
47 | 50 | ||
48 | this.title = title | 51 | this.title = title |
49 | 52 | ||
@@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit { | |||
53 | } else if (type === 'confirm-password') { | 56 | } else if (type === 'confirm-password') { |
54 | this.inputLabel = $localize`Confirm your password` | 57 | this.inputLabel = $localize`Confirm your password` |
55 | this.isPasswordInput = true | 58 | this.isPasswordInput = true |
59 | this.errorMessage = errorMessage | ||
56 | } | 60 | } |
57 | 61 | ||
58 | this.confirmButtonText = confirmButtonText || $localize`Confirm` | 62 | this.confirmButtonText = confirmButtonText || $localize`Confirm` |
@@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit { | |||
78 | return this.expectedInputValue !== this.inputValue | 82 | return this.expectedInputValue !== this.inputValue |
79 | } | 83 | } |
80 | 84 | ||
85 | hasError () { | ||
86 | return this.errorMessage | ||
87 | } | ||
81 | showModal () { | 88 | showModal () { |
82 | this.inputValue = '' | 89 | this.inputValue = '' |
83 | 90 | ||
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts index a4bda8f16..090a76e43 100644 --- a/client/src/app/shared/form-validators/video-validators.ts +++ b/client/src/app/shared/form-validators/video-validators.ts | |||
@@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = { | |||
26 | } | 26 | } |
27 | } | 27 | } |
28 | 28 | ||
29 | export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = { | ||
30 | VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically | ||
31 | MESSAGES: { | ||
32 | minLength: $localize`A password should be at least 2 characters long.`, | ||
33 | maxLength: $localize`A password should be shorter than 100 characters long.`, | ||
34 | required: $localize`A password is required for password protected video.` | ||
35 | } | ||
36 | } | ||
37 | |||
29 | export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { | 38 | export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { |
30 | VALIDATORS: [ ], | 39 | VALIDATORS: [ ], |
31 | MESSAGES: {} | 40 | MESSAGES: {} |
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index d3ec31d6e..480277450 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts | |||
@@ -52,6 +52,7 @@ import { | |||
52 | VideoFileTokenService, | 52 | VideoFileTokenService, |
53 | VideoImportService, | 53 | VideoImportService, |
54 | VideoOwnershipService, | 54 | VideoOwnershipService, |
55 | VideoPasswordService, | ||
55 | VideoResolver, | 56 | VideoResolver, |
56 | VideoService | 57 | VideoService |
57 | } from './video' | 58 | } from './video' |
@@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel' | |||
210 | 211 | ||
211 | VideoChannelService, | 212 | VideoChannelService, |
212 | 213 | ||
214 | VideoPasswordService, | ||
215 | |||
213 | CustomPageService, | 216 | CustomPageService, |
214 | 217 | ||
215 | ActorRedirectGuard | 218 | ActorRedirectGuard |
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts index 0f3afd116..21f31a717 100644 --- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts +++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts | |||
@@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http' | |||
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor, ServerService } from '@app/core' | 5 | import { RestExtractor, ServerService } from '@app/core' |
6 | import { objectToFormData, sortBy } from '@app/helpers' | 6 | import { objectToFormData, sortBy } from '@app/helpers' |
7 | import { VideoService } from '@app/shared/shared-main/video' | 7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' |
8 | import { peertubeTranslate } from '@shared/core-utils/i18n' | 8 | import { peertubeTranslate } from '@shared/core-utils/i18n' |
9 | import { ResultList, VideoCaption } from '@shared/models' | 9 | import { ResultList, VideoCaption } from '@shared/models' |
10 | import { environment } from '../../../../environments/environment' | 10 | import { environment } from '../../../../environments/environment' |
@@ -18,8 +18,10 @@ export class VideoCaptionService { | |||
18 | private restExtractor: RestExtractor | 18 | private restExtractor: RestExtractor |
19 | ) {} | 19 | ) {} |
20 | 20 | ||
21 | listCaptions (videoId: string): Observable<ResultList<VideoCaption>> { | 21 | listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> { |
22 | return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`) | 22 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) |
23 | |||
24 | return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers }) | ||
23 | .pipe( | 25 | .pipe( |
24 | switchMap(captionsResult => { | 26 | switchMap(captionsResult => { |
25 | return this.serverService.getServerLocale() | 27 | return this.serverService.getServerLocale() |
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index a2e47883e..07d40b117 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts | |||
@@ -5,6 +5,7 @@ export * from './video-edit.model' | |||
5 | export * from './video-file-token.service' | 5 | export * from './video-file-token.service' |
6 | export * from './video-import.service' | 6 | export * from './video-import.service' |
7 | export * from './video-ownership.service' | 7 | export * from './video-ownership.service' |
8 | export * from './video-password.service' | ||
8 | export * from './video.model' | 9 | export * from './video.model' |
9 | export * from './video.resolver' | 10 | export * from './video.resolver' |
10 | export * from './video.service' | 11 | export * from './video.service' |
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts index 47eee80d8..1b8b67ee2 100644 --- a/client/src/app/shared/shared-main/video/video-edit.model.ts +++ b/client/src/app/shared/shared-main/video/video-edit.model.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { getAbsoluteAPIUrl } from '@app/helpers' | 1 | import { getAbsoluteAPIUrl } from '@app/helpers' |
2 | import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' | 2 | import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' |
3 | import { VideoDetails } from './video-details.model' | 3 | import { VideoDetails } from './video-details.model' |
4 | import { objectKeysTyped } from '@shared/core-utils' | 4 | import { objectKeysTyped } from '@shared/core-utils' |
5 | 5 | ||
@@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate { | |||
18 | waitTranscoding: boolean | 18 | waitTranscoding: boolean |
19 | channelId: number | 19 | channelId: number |
20 | privacy: VideoPrivacy | 20 | privacy: VideoPrivacy |
21 | videoPassword?: string | ||
21 | support: string | 22 | support: string |
22 | thumbnailfile?: any | 23 | thumbnailfile?: any |
23 | previewfile?: any | 24 | previewfile?: any |
@@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate { | |||
32 | 33 | ||
33 | pluginData?: any | 34 | pluginData?: any |
34 | 35 | ||
35 | constructor (video?: VideoDetails) { | 36 | constructor (video?: VideoDetails, videoPassword?: VideoPassword) { |
36 | if (!video) return | 37 | if (!video) return |
37 | 38 | ||
38 | this.id = video.id | 39 | this.id = video.id |
@@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate { | |||
63 | : null | 64 | : null |
64 | 65 | ||
65 | this.pluginData = video.pluginData | 66 | this.pluginData = video.pluginData |
67 | |||
68 | if (videoPassword) this.videoPassword = videoPassword.password | ||
66 | } | 69 | } |
67 | 70 | ||
68 | patch (values: { [ id: string ]: any }) { | 71 | patch (values: { [ id: string ]: any }) { |
@@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate { | |||
112 | waitTranscoding: this.waitTranscoding, | 115 | waitTranscoding: this.waitTranscoding, |
113 | channelId: this.channelId, | 116 | channelId: this.channelId, |
114 | privacy: this.privacy, | 117 | privacy: this.privacy, |
118 | videoPassword: this.videoPassword, | ||
115 | originallyPublishedAt: this.originallyPublishedAt | 119 | originallyPublishedAt: this.originallyPublishedAt |
116 | } | 120 | } |
117 | 121 | ||
diff --git a/client/src/app/shared/shared-main/video/video-file-token.service.ts b/client/src/app/shared/shared-main/video/video-file-token.service.ts index 791607249..9bca5b9ec 100644 --- a/client/src/app/shared/shared-main/video/video-file-token.service.ts +++ b/client/src/app/shared/shared-main/video/video-file-token.service.ts | |||
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core' | |||
4 | import { RestExtractor } from '@app/core' | 4 | import { RestExtractor } from '@app/core' |
5 | import { VideoToken } from '@shared/models' | 5 | import { VideoToken } from '@shared/models' |
6 | import { VideoService } from './video.service' | 6 | import { VideoService } from './video.service' |
7 | import { VideoPasswordService } from './video-password.service' | ||
7 | 8 | ||
8 | @Injectable() | 9 | @Injectable() |
9 | export class VideoFileTokenService { | 10 | export class VideoFileTokenService { |
@@ -15,16 +16,18 @@ export class VideoFileTokenService { | |||
15 | private restExtractor: RestExtractor | 16 | private restExtractor: RestExtractor |
16 | ) {} | 17 | ) {} |
17 | 18 | ||
18 | getVideoFileToken (videoUUID: string) { | 19 | getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) { |
19 | const existing = this.store.get(videoUUID) | 20 | const existing = this.store.get(videoUUID) |
20 | if (existing) return of(existing) | 21 | if (existing) return of(existing) |
21 | 22 | ||
22 | return this.createVideoFileToken(videoUUID) | 23 | return this.createVideoFileToken(videoUUID, videoPassword) |
23 | .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) | 24 | .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) |
24 | } | 25 | } |
25 | 26 | ||
26 | private createVideoFileToken (videoUUID: string) { | 27 | private createVideoFileToken (videoUUID: string, videoPassword?: string) { |
27 | return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) | 28 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) |
29 | |||
30 | return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers }) | ||
28 | .pipe( | 31 | .pipe( |
29 | map(({ files }) => files), | 32 | map(({ files }) => files), |
30 | catchError(err => this.restExtractor.handleError(err)) | 33 | catchError(err => this.restExtractor.handleError(err)) |
diff --git a/client/src/app/shared/shared-main/video/video-password.service.ts b/client/src/app/shared/shared-main/video/video-password.service.ts new file mode 100644 index 000000000..d5b0406f8 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-password.service.ts | |||
@@ -0,0 +1,29 @@ | |||
1 | import { ResultList, VideoPassword } from '@shared/models' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { catchError, switchMap } from 'rxjs' | ||
4 | import { HttpClient, HttpHeaders } from '@angular/common/http' | ||
5 | import { RestExtractor } from '@app/core' | ||
6 | import { VideoService } from './video.service' | ||
7 | |||
8 | @Injectable() | ||
9 | export class VideoPasswordService { | ||
10 | |||
11 | constructor ( | ||
12 | private authHttp: HttpClient, | ||
13 | private restExtractor: RestExtractor | ||
14 | ) {} | ||
15 | |||
16 | static buildVideoPasswordHeader (videoPassword: string) { | ||
17 | return videoPassword | ||
18 | ? new HttpHeaders().set('x-peertube-video-password', videoPassword) | ||
19 | : undefined | ||
20 | } | ||
21 | |||
22 | getVideoPasswords (options: { videoUUID: string }) { | ||
23 | return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`) | ||
24 | .pipe( | ||
25 | switchMap(res => res.data), | ||
26 | catchError(err => this.restExtractor.handleError(err)) | ||
27 | ) | ||
28 | } | ||
29 | } | ||
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 6fdffb394..24c00c3d5 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts | |||
@@ -281,6 +281,13 @@ export class Video implements VideoServerModel { | |||
281 | return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) | 281 | return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) |
282 | } | 282 | } |
283 | 283 | ||
284 | canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) { | ||
285 | return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && | ||
286 | user && | ||
287 | this.isLocal === true && | ||
288 | (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS)) | ||
289 | } | ||
290 | |||
284 | getExactNumberOfViews () { | 291 | getExactNumberOfViews () { |
285 | if (this.isLive) { | 292 | if (this.isLive) { |
286 | return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) | 293 | return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) |
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 78a49567f..d67a2e192 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts | |||
@@ -33,6 +33,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel' | |||
33 | import { VideoDetails } from './video-details.model' | 33 | import { VideoDetails } from './video-details.model' |
34 | import { VideoEdit } from './video-edit.model' | 34 | import { VideoEdit } from './video-edit.model' |
35 | import { Video } from './video.model' | 35 | import { Video } from './video.model' |
36 | import { VideoPasswordService } from './video-password.service' | ||
36 | 37 | ||
37 | export type CommonVideoParams = { | 38 | export type CommonVideoParams = { |
38 | videoPagination?: ComponentPaginationLight | 39 | videoPagination?: ComponentPaginationLight |
@@ -69,16 +70,17 @@ export class VideoService { | |||
69 | return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` | 70 | return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` |
70 | } | 71 | } |
71 | 72 | ||
72 | getVideo (options: { videoId: string }): Observable<VideoDetails> { | 73 | getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> { |
73 | return this.serverService.getServerLocale() | 74 | const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword) |
74 | .pipe( | 75 | |
75 | switchMap(translations => { | 76 | return this.serverService.getServerLocale().pipe( |
76 | return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`) | 77 | switchMap(translations => { |
77 | .pipe(map(videoHash => ({ videoHash, translations }))) | 78 | return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers }) |
78 | }), | 79 | .pipe(map(videoHash => ({ videoHash, translations }))) |
79 | map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), | 80 | }), |
80 | catchError(err => this.restExtractor.handleError(err)) | 81 | map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), |
81 | ) | 82 | catchError(err => this.restExtractor.handleError(err)) |
83 | ) | ||
82 | } | 84 | } |
83 | 85 | ||
84 | updateVideo (video: VideoEdit) { | 86 | updateVideo (video: VideoEdit) { |
@@ -99,6 +101,9 @@ export class VideoService { | |||
99 | description, | 101 | description, |
100 | channelId: video.channelId, | 102 | channelId: video.channelId, |
101 | privacy: video.privacy, | 103 | privacy: video.privacy, |
104 | videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED | ||
105 | ? [ video.videoPassword ] | ||
106 | : undefined, | ||
102 | tags: video.tags, | 107 | tags: video.tags, |
103 | nsfw: video.nsfw, | 108 | nsfw: video.nsfw, |
104 | waitTranscoding: video.waitTranscoding, | 109 | waitTranscoding: video.waitTranscoding, |
@@ -353,16 +358,16 @@ export class VideoService { | |||
353 | ) | 358 | ) |
354 | } | 359 | } |
355 | 360 | ||
356 | setVideoLike (id: string) { | 361 | setVideoLike (id: string, videoPassword: string) { |
357 | return this.setVideoRate(id, 'like') | 362 | return this.setVideoRate(id, 'like', videoPassword) |
358 | } | 363 | } |
359 | 364 | ||
360 | setVideoDislike (id: string) { | 365 | setVideoDislike (id: string, videoPassword: string) { |
361 | return this.setVideoRate(id, 'dislike') | 366 | return this.setVideoRate(id, 'dislike', videoPassword) |
362 | } | 367 | } |
363 | 368 | ||
364 | unsetVideoLike (id: string) { | 369 | unsetVideoLike (id: string, videoPassword: string) { |
365 | return this.setVideoRate(id, 'none') | 370 | return this.setVideoRate(id, 'none', videoPassword) |
366 | } | 371 | } |
367 | 372 | ||
368 | getUserVideoRating (id: string) { | 373 | getUserVideoRating (id: string) { |
@@ -394,7 +399,8 @@ export class VideoService { | |||
394 | [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, | 399 | [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, |
395 | [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, | 400 | [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, |
396 | [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, | 401 | [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, |
397 | [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video` | 402 | [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`, |
403 | [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video` | ||
398 | } | 404 | } |
399 | 405 | ||
400 | const videoPrivacies = serverPrivacies.map(p => { | 406 | const videoPrivacies = serverPrivacies.map(p => { |
@@ -412,7 +418,13 @@ export class VideoService { | |||
412 | } | 418 | } |
413 | 419 | ||
414 | getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { | 420 | getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { |
415 | const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ] | 421 | // We do not add a password as this requires additional configuration. |
422 | const order = [ | ||
423 | VideoPrivacy.PRIVATE, | ||
424 | VideoPrivacy.INTERNAL, | ||
425 | VideoPrivacy.UNLISTED, | ||
426 | VideoPrivacy.PUBLIC | ||
427 | ] | ||
416 | 428 | ||
417 | for (const privacy of order) { | 429 | for (const privacy of order) { |
418 | if (serverPrivacies.find(p => p.id === privacy)) { | 430 | if (serverPrivacies.find(p => p.id === privacy)) { |
@@ -499,14 +511,15 @@ export class VideoService { | |||
499 | } | 511 | } |
500 | } | 512 | } |
501 | 513 | ||
502 | private setVideoRate (id: string, rateType: UserVideoRateType) { | 514 | private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) { |
503 | const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` | 515 | const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` |
504 | const body: UserVideoRateUpdate = { | 516 | const body: UserVideoRateUpdate = { |
505 | rating: rateType | 517 | rating: rateType |
506 | } | 518 | } |
519 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) | ||
507 | 520 | ||
508 | return this.authHttp | 521 | return this.authHttp |
509 | .put(url, body) | 522 | .put(url, body, { headers }) |
510 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 523 | .pipe(catchError(err => this.restExtractor.handleError(err))) |
511 | } | 524 | } |
512 | } | 525 | } |
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html index 5650fa948..9f1455561 100644 --- a/client/src/app/shared/shared-share-modal/video-share.component.html +++ b/client/src/app/shared/shared-share-modal/video-share.component.html | |||
@@ -107,6 +107,10 @@ | |||
107 | </a> | 107 | </a> |
108 | </div> | 108 | </div> |
109 | 109 | ||
110 | <div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning"> | ||
111 | This video is password protected, please note that recipients will require the corresponding password to access the content. | ||
112 | </div> | ||
113 | |||
110 | <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId"> | 114 | <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId"> |
111 | 115 | ||
112 | <ng-container ngbNavItem="url"> | 116 | <ng-container ngbNavItem="url"> |
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts index 32f900f15..da4f2a4b4 100644 --- a/client/src/app/shared/shared-share-modal/video-share.component.ts +++ b/client/src/app/shared/shared-share-modal/video-share.component.ts | |||
@@ -243,6 +243,10 @@ export class VideoShareComponent { | |||
243 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE | 243 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE |
244 | } | 244 | } |
245 | 245 | ||
246 | isPasswordProtectedVideo () { | ||
247 | return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
248 | } | ||
249 | |||
246 | private getPlaylistOptions (baseUrl?: string) { | 250 | private getPlaylistOptions (baseUrl?: string) { |
247 | return { | 251 | return { |
248 | baseUrl, | 252 | baseUrl, |
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts index 8d2deedf7..3906652be 100644 --- a/client/src/app/shared/shared-video-comment/video-comment.service.ts +++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts | |||
@@ -18,6 +18,7 @@ import { | |||
18 | import { environment } from '../../../environments/environment' | 18 | import { environment } from '../../../environments/environment' |
19 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | 19 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' |
20 | import { VideoComment } from './video-comment.model' | 20 | import { VideoComment } from './video-comment.model' |
21 | import { VideoPasswordService } from '../shared-main' | ||
21 | 22 | ||
22 | @Injectable() | 23 | @Injectable() |
23 | export class VideoCommentService { | 24 | export class VideoCommentService { |
@@ -31,22 +32,25 @@ export class VideoCommentService { | |||
31 | private restService: RestService | 32 | private restService: RestService |
32 | ) {} | 33 | ) {} |
33 | 34 | ||
34 | addCommentThread (videoId: string, comment: VideoCommentCreate) { | 35 | addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) { |
36 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) | ||
35 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | 37 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' |
36 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | 38 | const normalizedComment = objectLineFeedToHtml(comment, 'text') |
37 | 39 | ||
38 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | 40 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers }) |
39 | .pipe( | 41 | .pipe( |
40 | map(data => this.extractVideoComment(data.comment)), | 42 | map(data => this.extractVideoComment(data.comment)), |
41 | catchError(err => this.restExtractor.handleError(err)) | 43 | catchError(err => this.restExtractor.handleError(err)) |
42 | ) | 44 | ) |
43 | } | 45 | } |
44 | 46 | ||
45 | addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) { | 47 | addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) { |
48 | const { videoId, inReplyToCommentId, comment, videoPassword } = options | ||
49 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) | ||
46 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId | 50 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId |
47 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | 51 | const normalizedComment = objectLineFeedToHtml(comment, 'text') |
48 | 52 | ||
49 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | 53 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers }) |
50 | .pipe( | 54 | .pipe( |
51 | map(data => this.extractVideoComment(data.comment)), | 55 | map(data => this.extractVideoComment(data.comment)), |
52 | catchError(err => this.restExtractor.handleError(err)) | 56 | catchError(err => this.restExtractor.handleError(err)) |
@@ -76,10 +80,13 @@ export class VideoCommentService { | |||
76 | 80 | ||
77 | getVideoCommentThreads (parameters: { | 81 | getVideoCommentThreads (parameters: { |
78 | videoId: string | 82 | videoId: string |
83 | videoPassword: string | ||
79 | componentPagination: ComponentPaginationLight | 84 | componentPagination: ComponentPaginationLight |
80 | sort: string | 85 | sort: string |
81 | }): Observable<ThreadsResultList<VideoComment>> { | 86 | }): Observable<ThreadsResultList<VideoComment>> { |
82 | const { videoId, componentPagination, sort } = parameters | 87 | const { videoId, videoPassword, componentPagination, sort } = parameters |
88 | |||
89 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) | ||
83 | 90 | ||
84 | const pagination = this.restService.componentToRestPagination(componentPagination) | 91 | const pagination = this.restService.componentToRestPagination(componentPagination) |
85 | 92 | ||
@@ -87,7 +94,7 @@ export class VideoCommentService { | |||
87 | params = this.restService.addRestGetParams(params, pagination, sort) | 94 | params = this.restService.addRestGetParams(params, pagination, sort) |
88 | 95 | ||
89 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | 96 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' |
90 | return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params }) | 97 | return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params, headers }) |
91 | .pipe( | 98 | .pipe( |
92 | map(result => this.extractVideoComments(result)), | 99 | map(result => this.extractVideoComments(result)), |
93 | catchError(err => this.restExtractor.handleError(err)) | 100 | catchError(err => this.restExtractor.handleError(err)) |
@@ -97,12 +104,14 @@ export class VideoCommentService { | |||
97 | getVideoThreadComments (parameters: { | 104 | getVideoThreadComments (parameters: { |
98 | videoId: string | 105 | videoId: string |
99 | threadId: number | 106 | threadId: number |
107 | videoPassword?: string | ||
100 | }): Observable<VideoCommentThreadTree> { | 108 | }): Observable<VideoCommentThreadTree> { |
101 | const { videoId, threadId } = parameters | 109 | const { videoId, threadId, videoPassword } = parameters |
102 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` | 110 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` |
111 | const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword) | ||
103 | 112 | ||
104 | return this.authHttp | 113 | return this.authHttp |
105 | .get<VideoCommentThreadTreeServerModel>(url) | 114 | .get<VideoCommentThreadTreeServerModel>(url, { headers }) |
106 | .pipe( | 115 | .pipe( |
107 | map(tree => this.extractVideoCommentTree(tree)), | 116 | map(tree => this.extractVideoCommentTree(tree)), |
108 | catchError(err => this.restExtractor.handleError(err)) | 117 | catchError(err => this.restExtractor.handleError(err)) |
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts index cac82d8d0..146ea7dfe 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts | |||
@@ -1,13 +1,13 @@ | |||
1 | import { mapValues } from 'lodash-es' | 1 | import { mapValues } from 'lodash-es' |
2 | import { firstValueFrom } from 'rxjs' | 2 | import { firstValueFrom } from 'rxjs' |
3 | import { tap } from 'rxjs/operators' | 3 | import { tap } from 'rxjs/operators' |
4 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' | 4 | import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core' |
5 | import { HooksService } from '@app/core' | 5 | import { HooksService } from '@app/core' |
6 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' |
7 | import { logger } from '@root-helpers/logger' | 7 | import { logger } from '@root-helpers/logger' |
8 | import { videoRequiresAuth } from '@root-helpers/video' | 8 | import { videoRequiresFileToken } from '@root-helpers/video' |
9 | import { objectKeysTyped, pick } from '@shared/core-utils' | 9 | import { objectKeysTyped, pick } from '@shared/core-utils' |
10 | import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' | 10 | import { VideoCaption, VideoFile } from '@shared/models' |
11 | import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' | 11 | import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' |
12 | 12 | ||
13 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } } | |||
21 | export class VideoDownloadComponent { | 21 | export class VideoDownloadComponent { |
22 | @ViewChild('modal', { static: true }) modal: ElementRef | 22 | @ViewChild('modal', { static: true }) modal: ElementRef |
23 | 23 | ||
24 | @Input() videoPassword: string | ||
25 | |||
24 | downloadType: 'direct' | 'torrent' = 'direct' | 26 | downloadType: 'direct' | 'torrent' = 'direct' |
25 | 27 | ||
26 | resolutionId: number | string = -1 | 28 | resolutionId: number | string = -1 |
@@ -89,8 +91,8 @@ export class VideoDownloadComponent { | |||
89 | this.subtitleLanguageId = this.videoCaptions[0].language.id | 91 | this.subtitleLanguageId = this.videoCaptions[0].language.id |
90 | } | 92 | } |
91 | 93 | ||
92 | if (videoRequiresAuth(this.video)) { | 94 | if (this.isConfidentialVideo()) { |
93 | this.videoFileTokenService.getVideoFileToken(this.video.uuid) | 95 | this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword }) |
94 | .subscribe(({ token }) => this.videoFileToken = token) | 96 | .subscribe(({ token }) => this.videoFileToken = token) |
95 | } | 97 | } |
96 | 98 | ||
@@ -201,7 +203,8 @@ export class VideoDownloadComponent { | |||
201 | } | 203 | } |
202 | 204 | ||
203 | isConfidentialVideo () { | 205 | isConfidentialVideo () { |
204 | return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL | 206 | return videoRequiresFileToken(this.video) |
207 | |||
205 | } | 208 | } |
206 | 209 | ||
207 | switchToType (type: DownloadType) { | 210 | switchToType (type: DownloadType) { |
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html index 3d39c6fdc..3fbfaed28 100644 --- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html | |||
@@ -125,7 +125,7 @@ | |||
125 | <my-peertube-checkbox | 125 | <my-peertube-checkbox |
126 | formControlName="allVideos" | 126 | formControlName="allVideos" |
127 | inputName="allVideos" | 127 | inputName="allVideos" |
128 | i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" | 128 | i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)" |
129 | ></my-peertube-checkbox> | 129 | ></my-peertube-checkbox> |
130 | </div> | 130 | </div> |
131 | </div> | 131 | </div> |
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html index 3f0180695..9e0a4f79b 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html | |||
@@ -5,6 +5,7 @@ | |||
5 | > | 5 | > |
6 | <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> | 6 | <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> |
7 | <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> | 7 | <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> |
8 | <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container> | ||
8 | </my-video-thumbnail> | 9 | </my-video-thumbnail> |
9 | 10 | ||
10 | <div class="video-bottom"> | 11 | <div class="video-bottom"> |
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts index 2384b34d7..d453f37a1 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts | |||
@@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit { | |||
171 | return this.video.privacy.id === VideoPrivacy.PRIVATE | 171 | return this.video.privacy.id === VideoPrivacy.PRIVATE |
172 | } | 172 | } |
173 | 173 | ||
174 | isPasswordProtectedVideo () { | ||
175 | return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
176 | } | ||
177 | |||
174 | getStateLabel (video: Video) { | 178 | getStateLabel (video: Video) { |
175 | if (!video.state) return '' | 179 | if (!video.state) return '' |
176 | 180 | ||
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts index 7b832263e..45df0be38 100644 --- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts | |||
@@ -241,6 +241,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | |||
241 | } | 241 | } |
242 | 242 | ||
243 | reloadVideos () { | 243 | reloadVideos () { |
244 | console.log('reload') | ||
244 | this.pagination.currentPage = 1 | 245 | this.pagination.currentPage = 1 |
245 | this.loadMoreVideos(true) | 246 | this.loadMoreVideos(true) |
246 | } | 247 | } |
@@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | |||
420 | 421 | ||
421 | if (reset) this.videos = [] | 422 | if (reset) this.videos = [] |
422 | this.videos = this.videos.concat(data) | 423 | this.videos = this.videos.concat(data) |
423 | 424 | console.log('subscribe') | |
424 | if (this.groupByDate) this.buildGroupedDateLabels() | 425 | if (this.groupByDate) this.buildGroupedDateLabels() |
425 | 426 | ||
426 | this.onDataSubject.next(data) | 427 | this.onDataSubject.next(data) |
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html index 75afa0709..882b14c5e 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html | |||
@@ -21,7 +21,8 @@ | |||
21 | [attr.title]="playlistElement.video.name" | 21 | [attr.title]="playlistElement.video.name" |
22 | >{{ playlistElement.video.name }}</a> | 22 | >{{ playlistElement.video.name }}</a> |
23 | 23 | ||
24 | <span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> | 24 | <span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> |
25 | <span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span> | ||
25 | </div> | 26 | </div> |
26 | 27 | ||
27 | <span class="video-miniature-created-at-views"> | 28 | <span class="video-miniature-created-at-views"> |
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts index 552ea742b..b9a1d9623 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts | |||
@@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit { | |||
60 | return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE | 60 | return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE |
61 | } | 61 | } |
62 | 62 | ||
63 | isVideoPasswordProtected () { | ||
64 | return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED | ||
65 | } | ||
66 | |||
63 | isUnavailable (e: VideoPlaylistElement) { | 67 | isUnavailable (e: VideoPlaylistElement) { |
64 | return e.type === VideoPlaylistElementType.UNAVAILABLE | 68 | return e.type === VideoPlaylistElementType.UNAVAILABLE |
65 | } | 69 | } |
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/manager-options/hls-options-builder.ts index 194991fa4..8091110bc 100644 --- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts +++ b/client/src/assets/player/shared/manager-options/hls-options-builder.ts | |||
@@ -31,7 +31,7 @@ export class HLSOptionsBuilder { | |||
31 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader | 31 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader |
32 | 32 | ||
33 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | 33 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { |
34 | requiresAuth: commonOptions.requiresAuth, | 34 | requiresUserAuth: commonOptions.requiresUserAuth, |
35 | videoFileToken: commonOptions.videoFileToken, | 35 | videoFileToken: commonOptions.videoFileToken, |
36 | 36 | ||
37 | redundancyUrlManager, | 37 | redundancyUrlManager, |
@@ -88,17 +88,24 @@ export class HLSOptionsBuilder { | |||
88 | httpFailedSegmentTimeout: 1000, | 88 | httpFailedSegmentTimeout: 1000, |
89 | 89 | ||
90 | xhrSetup: (xhr, url) => { | 90 | xhrSetup: (xhr, url) => { |
91 | if (!this.options.common.requiresAuth) return | 91 | const { requiresUserAuth, requiresPassword } = this.options.common |
92 | |||
93 | if (!(requiresUserAuth || requiresPassword)) return | ||
94 | |||
92 | if (!isSameOrigin(this.options.common.serverUrl, url)) return | 95 | if (!isSameOrigin(this.options.common.serverUrl, url)) return |
93 | 96 | ||
94 | xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) | 97 | if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword()) |
98 | |||
99 | else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) | ||
95 | }, | 100 | }, |
96 | 101 | ||
97 | segmentValidator: segmentValidatorFactory({ | 102 | segmentValidator: segmentValidatorFactory({ |
98 | segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, | 103 | segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, |
99 | authorizationHeader: this.options.common.authorizationHeader, | 104 | authorizationHeader: this.options.common.authorizationHeader, |
100 | requiresAuth: this.options.common.requiresAuth, | 105 | requiresUserAuth: this.options.common.requiresUserAuth, |
101 | serverUrl: this.options.common.serverUrl | 106 | serverUrl: this.options.common.serverUrl, |
107 | requiresPassword: this.options.common.requiresPassword, | ||
108 | videoPassword: this.options.common.videoPassword | ||
102 | }), | 109 | }), |
103 | 110 | ||
104 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), | 111 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), |
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts index b5bdcd4e6..80eec02cf 100644 --- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts +++ b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts | |||
@@ -26,10 +26,10 @@ export class WebTorrentOptionsBuilder { | |||
26 | 26 | ||
27 | videoFileToken: commonOptions.videoFileToken, | 27 | videoFileToken: commonOptions.videoFileToken, |
28 | 28 | ||
29 | requiresAuth: commonOptions.requiresAuth, | 29 | requiresUserAuth: commonOptions.requiresUserAuth, |
30 | 30 | ||
31 | buildWebSeedUrls: file => { | 31 | buildWebSeedUrls: file => { |
32 | if (!commonOptions.requiresAuth) return [] | 32 | if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return [] |
33 | 33 | ||
34 | return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] | 34 | return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] |
35 | }, | 35 | }, |
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index 44a31bfb4..e86d3d159 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts | |||
@@ -13,11 +13,20 @@ function segmentValidatorFactory (options: { | |||
13 | serverUrl: string | 13 | serverUrl: string |
14 | segmentsSha256Url: string | 14 | segmentsSha256Url: string |
15 | authorizationHeader: () => string | 15 | authorizationHeader: () => string |
16 | requiresAuth: boolean | 16 | requiresUserAuth: boolean |
17 | requiresPassword: boolean | ||
18 | videoPassword: () => string | ||
17 | }) { | 19 | }) { |
18 | const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options | 20 | const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options |
19 | 21 | ||
20 | let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) | 22 | let segmentsJSON = fetchSha256Segments({ |
23 | serverUrl, | ||
24 | segmentsSha256Url, | ||
25 | authorizationHeader, | ||
26 | requiresUserAuth, | ||
27 | requiresPassword, | ||
28 | videoPassword | ||
29 | }) | ||
21 | const regex = /bytes=(\d+)-(\d+)/ | 30 | const regex = /bytes=(\d+)-(\d+)/ |
22 | 31 | ||
23 | return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { | 32 | return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { |
@@ -34,7 +43,14 @@ function segmentValidatorFactory (options: { | |||
34 | 43 | ||
35 | await wait(500) | 44 | await wait(500) |
36 | 45 | ||
37 | segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) | 46 | segmentsJSON = fetchSha256Segments({ |
47 | serverUrl, | ||
48 | segmentsSha256Url, | ||
49 | authorizationHeader, | ||
50 | requiresUserAuth, | ||
51 | requiresPassword, | ||
52 | videoPassword | ||
53 | }) | ||
38 | await segmentValidator(segment, _method, _peerId, retry + 1) | 54 | await segmentValidator(segment, _method, _peerId, retry + 1) |
39 | 55 | ||
40 | return | 56 | return |
@@ -78,13 +94,17 @@ function fetchSha256Segments (options: { | |||
78 | serverUrl: string | 94 | serverUrl: string |
79 | segmentsSha256Url: string | 95 | segmentsSha256Url: string |
80 | authorizationHeader: () => string | 96 | authorizationHeader: () => string |
81 | requiresAuth: boolean | 97 | requiresUserAuth: boolean |
98 | requiresPassword: boolean | ||
99 | videoPassword: () => string | ||
82 | }): Promise<SegmentsJSON> { | 100 | }): Promise<SegmentsJSON> { |
83 | const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options | 101 | const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options |
84 | 102 | ||
85 | const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url) | 103 | let headers: { [ id: string ]: string } = {} |
86 | ? { Authorization: authorizationHeader() } | 104 | if (isSameOrigin(serverUrl, segmentsSha256Url)) { |
87 | : {} | 105 | if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() } |
106 | else if (requiresUserAuth) headers = { Authorization: authorizationHeader() } | ||
107 | } | ||
88 | 108 | ||
89 | return fetch(segmentsSha256Url, { headers }) | 109 | return fetch(segmentsSha256Url, { headers }) |
90 | .then(res => res.json() as Promise<SegmentsJSON>) | 110 | .then(res => res.json() as Promise<SegmentsJSON>) |
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts index 3dde44a60..e2e220c03 100644 --- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts | |||
@@ -59,7 +59,7 @@ class WebTorrentPlugin extends Plugin { | |||
59 | private isAutoResolutionObservation = false | 59 | private isAutoResolutionObservation = false |
60 | private playerRefusedP2P = false | 60 | private playerRefusedP2P = false |
61 | 61 | ||
62 | private requiresAuth: boolean | 62 | private requiresUserAuth: boolean |
63 | private videoFileToken: () => string | 63 | private videoFileToken: () => string |
64 | 64 | ||
65 | private torrentInfoInterval: any | 65 | private torrentInfoInterval: any |
@@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin { | |||
86 | this.savePlayerSrcFunction = this.player.src | 86 | this.savePlayerSrcFunction = this.player.src |
87 | this.playerElement = options.playerElement | 87 | this.playerElement = options.playerElement |
88 | 88 | ||
89 | this.requiresAuth = options.requiresAuth | 89 | this.requiresUserAuth = options.requiresUserAuth |
90 | this.videoFileToken = options.videoFileToken | 90 | this.videoFileToken = options.videoFileToken |
91 | 91 | ||
92 | this.buildWebSeedUrls = options.buildWebSeedUrls | 92 | this.buildWebSeedUrls = options.buildWebSeedUrls |
@@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin { | |||
546 | 546 | ||
547 | let httpUrl = this.currentVideoFile.fileUrl | 547 | let httpUrl = this.currentVideoFile.fileUrl |
548 | 548 | ||
549 | if (this.requiresAuth && this.videoFileToken) { | 549 | if (this.videoFileToken) { |
550 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) | 550 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) |
551 | } | 551 | } |
552 | 552 | ||
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts index c14fd7e99..1f3a0aa2e 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/manager-options.ts | |||
@@ -83,8 +83,10 @@ export interface CommonOptions extends CustomizationOptions { | |||
83 | videoShortUUID: string | 83 | videoShortUUID: string |
84 | 84 | ||
85 | serverUrl: string | 85 | serverUrl: string |
86 | requiresAuth: boolean | 86 | requiresUserAuth: boolean |
87 | videoFileToken: () => string | 87 | videoFileToken: () => string |
88 | requiresPassword: boolean | ||
89 | videoPassword: () => string | ||
88 | 90 | ||
89 | errorNotifier: (message: string) => void | 91 | errorNotifier: (message: string) => void |
90 | } | 92 | } |
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index eadf56cfa..723c42c5d 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts | |||
@@ -155,7 +155,7 @@ type WebtorrentPluginOptions = { | |||
155 | 155 | ||
156 | playerRefusedP2P: boolean | 156 | playerRefusedP2P: boolean |
157 | 157 | ||
158 | requiresAuth: boolean | 158 | requiresUserAuth: boolean |
159 | videoFileToken: () => string | 159 | videoFileToken: () => string |
160 | 160 | ||
161 | buildWebSeedUrls: (file: VideoFile) => string[] | 161 | buildWebSeedUrls: (file: VideoFile) => string[] |
@@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = { | |||
170 | 170 | ||
171 | loader: P2PMediaLoader | 171 | loader: P2PMediaLoader |
172 | 172 | ||
173 | requiresAuth: boolean | 173 | requiresUserAuth: boolean |
174 | videoFileToken: () => string | 174 | videoFileToken: () => string |
175 | } | 175 | } |
176 | 176 | ||
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts index 9022b908b..4a44615fb 100644 --- a/client/src/root-helpers/video.ts +++ b/client/src/root-helpers/video.ts | |||
@@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b | |||
41 | return userP2PEnabled | 41 | return userP2PEnabled |
42 | } | 42 | } |
43 | 43 | ||
44 | function videoRequiresAuth (video: Video) { | 44 | function videoRequiresUserAuth (video: Video, videoPassword?: string) { |
45 | return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) | 45 | return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) || |
46 | (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword) | ||
47 | |||
48 | } | ||
49 | |||
50 | function videoRequiresFileToken (video: Video, videoPassword?: string) { | ||
51 | return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id) | ||
46 | } | 52 | } |
47 | 53 | ||
48 | export { | 54 | export { |
49 | buildVideoOrPlaylistEmbed, | 55 | buildVideoOrPlaylistEmbed, |
50 | isP2PEnabled, | 56 | isP2PEnabled, |
51 | videoRequiresAuth | 57 | videoRequiresUserAuth, |
58 | videoRequiresFileToken | ||
52 | } | 59 | } |
53 | 60 | ||
54 | // --------------------------------------------------------------------------- | 61 | // --------------------------------------------------------------------------- |
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html index 32bf5f655..a74bb4cee 100644 --- a/client/src/standalone/videos/embed.html +++ b/client/src/standalone/videos/embed.html | |||
@@ -41,6 +41,23 @@ | |||
41 | <div id="error-content"></div> | 41 | <div id="error-content"></div> |
42 | </div> | 42 | </div> |
43 | 43 | ||
44 | <div id="video-password-block"> | ||
45 | <!-- eslint-disable-next-line @angular-eslint/template/elements-content --> | ||
46 | <h1 id="video-password-title"></h1> | ||
47 | |||
48 | <div id="video-password-content"></div> | ||
49 | |||
50 | <form id="video-password-form"> | ||
51 | <input type="password" id="video-password-input" name="video-password" required> | ||
52 | <button type="submit" id="video-password-submit"> </button> | ||
53 | </form> | ||
54 | |||
55 | <div id="video-password-error"></div> | ||
56 | <svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24"> | ||
57 | <g fill="none" stroke="#c4c4c4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></g> | ||
58 | </svg> | ||
59 | </div> | ||
60 | |||
44 | <div id="video-wrapper"></div> | 61 | <div id="video-wrapper"></div> |
45 | 62 | ||
46 | <div id="placeholder-preview"></div> | 63 | <div id="placeholder-preview"></div> |
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss index 3631ea7e6..d15887478 100644 --- a/client/src/standalone/videos/embed.scss +++ b/client/src/standalone/videos/embed.scss | |||
@@ -24,7 +24,7 @@ html, | |||
24 | body { | 24 | body { |
25 | height: 100%; | 25 | height: 100%; |
26 | margin: 0; | 26 | margin: 0; |
27 | background-color: #000; | 27 | background-color: #0f0f10; |
28 | } | 28 | } |
29 | 29 | ||
30 | #video-wrapper { | 30 | #video-wrapper { |
@@ -42,8 +42,10 @@ body { | |||
42 | } | 42 | } |
43 | } | 43 | } |
44 | 44 | ||
45 | #error-block { | 45 | #error-block, |
46 | #video-password-block { | ||
46 | display: none; | 47 | display: none; |
48 | user-select: none; | ||
47 | 49 | ||
48 | flex-direction: column; | 50 | flex-direction: column; |
49 | align-content: center; | 51 | align-content: center; |
@@ -86,6 +88,43 @@ body { | |||
86 | text-align: center; | 88 | text-align: center; |
87 | } | 89 | } |
88 | 90 | ||
91 | #video-password-content { | ||
92 | @include margin(1rem, 0, 2rem); | ||
93 | } | ||
94 | |||
95 | #video-password-input, | ||
96 | #video-password-submit { | ||
97 | line-height: 23px; | ||
98 | padding: 1rem; | ||
99 | margin: 1rem 0.5rem; | ||
100 | border: 0; | ||
101 | font-weight: 600; | ||
102 | border-radius: 3px!important; | ||
103 | font-size: 18px; | ||
104 | display: inline-block; | ||
105 | } | ||
106 | |||
107 | #video-password-submit { | ||
108 | color: #fff; | ||
109 | background-color: #f2690d; | ||
110 | cursor: pointer; | ||
111 | } | ||
112 | |||
113 | #video-password-submit:hover { | ||
114 | background-color: #f47825; | ||
115 | } | ||
116 | #video-password-error { | ||
117 | margin-top: 10px; | ||
118 | margin-bottom: 10px; | ||
119 | height: 2rem; | ||
120 | font-weight: bolder; | ||
121 | } | ||
122 | |||
123 | #video-password-block svg { | ||
124 | margin-left: auto; | ||
125 | margin-right: auto; | ||
126 | } | ||
127 | |||
89 | @media screen and (max-width: 300px) { | 128 | @media screen and (max-width: 300px) { |
90 | #error-block { | 129 | #error-block { |
91 | font-size: 36px; | 130 | font-size: 36px; |
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index cc4274b99..cffda2cc7 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts | |||
@@ -3,10 +3,18 @@ import '../../assets/player/shared/dock/peertube-dock-component' | |||
3 | import '../../assets/player/shared/dock/peertube-dock-plugin' | 3 | import '../../assets/player/shared/dock/peertube-dock-plugin' |
4 | import videojs from 'video.js' | 4 | import videojs from 'video.js' |
5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' | 5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' |
6 | import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' | 6 | import { |
7 | HTMLServerConfig, | ||
8 | ResultList, | ||
9 | ServerErrorCode, | ||
10 | VideoDetails, | ||
11 | VideoPlaylist, | ||
12 | VideoPlaylistElement, | ||
13 | VideoState | ||
14 | } from '../../../../shared/models' | ||
7 | import { PeertubePlayerManager } from '../../assets/player' | 15 | import { PeertubePlayerManager } from '../../assets/player' |
8 | import { TranslationsManager } from '../../assets/player/translations-manager' | 16 | import { TranslationsManager } from '../../assets/player/translations-manager' |
9 | import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' | 17 | import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' |
10 | import { PeerTubeEmbedApi } from './embed-api' | 18 | import { PeerTubeEmbedApi } from './embed-api' |
11 | import { | 19 | import { |
12 | AuthHTTP, | 20 | AuthHTTP, |
@@ -19,6 +27,7 @@ import { | |||
19 | VideoFetcher | 27 | VideoFetcher |
20 | } from './shared' | 28 | } from './shared' |
21 | import { PlayerHTML } from './shared/player-html' | 29 | import { PlayerHTML } from './shared/player-html' |
30 | import { PeerTubeServerError } from 'src/types' | ||
22 | 31 | ||
23 | export class PeerTubeEmbed { | 32 | export class PeerTubeEmbed { |
24 | player: videojs.Player | 33 | player: videojs.Player |
@@ -38,6 +47,8 @@ export class PeerTubeEmbed { | |||
38 | private readonly liveManager: LiveManager | 47 | private readonly liveManager: LiveManager |
39 | 48 | ||
40 | private playlistTracker: PlaylistTracker | 49 | private playlistTracker: PlaylistTracker |
50 | private videoPassword: string | ||
51 | private requiresPassword: boolean | ||
41 | 52 | ||
42 | constructor (videoWrapperId: string) { | 53 | constructor (videoWrapperId: string) { |
43 | logger.registerServerSending(window.location.origin) | 54 | logger.registerServerSending(window.location.origin) |
@@ -50,6 +61,7 @@ export class PeerTubeEmbed { | |||
50 | this.playerHTML = new PlayerHTML(videoWrapperId) | 61 | this.playerHTML = new PlayerHTML(videoWrapperId) |
51 | this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) | 62 | this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) |
52 | this.liveManager = new LiveManager(this.playerHTML) | 63 | this.liveManager = new LiveManager(this.playerHTML) |
64 | this.requiresPassword = false | ||
53 | 65 | ||
54 | try { | 66 | try { |
55 | this.config = JSON.parse((window as any)['PeerTubeServerConfig']) | 67 | this.config = JSON.parse((window as any)['PeerTubeServerConfig']) |
@@ -176,11 +188,13 @@ export class PeerTubeEmbed { | |||
176 | const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options | 188 | const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options |
177 | 189 | ||
178 | try { | 190 | try { |
179 | const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) | 191 | const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) |
180 | 192 | ||
181 | return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) | 193 | return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) |
182 | } catch (err) { | 194 | } catch (err) { |
183 | this.playerHTML.displayError(err.message, await this.translationsPromise) | 195 | |
196 | if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) | ||
197 | else this.playerHTML.displayError(err.message, await this.translationsPromise) | ||
184 | } | 198 | } |
185 | } | 199 | } |
186 | 200 | ||
@@ -205,8 +219,8 @@ export class PeerTubeEmbed { | |||
205 | ? await this.videoFetcher.loadLive(videoInfo) | 219 | ? await this.videoFetcher.loadLive(videoInfo) |
206 | : undefined | 220 | : undefined |
207 | 221 | ||
208 | const videoFileToken = videoRequiresAuth(videoInfo) | 222 | const videoFileToken = videoRequiresFileToken(videoInfo) |
209 | ? await this.videoFetcher.loadVideoToken(videoInfo) | 223 | ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword) |
210 | : undefined | 224 | : undefined |
211 | 225 | ||
212 | return { live, video: videoInfo, videoFileToken } | 226 | return { live, video: videoInfo, videoFileToken } |
@@ -232,6 +246,8 @@ export class PeerTubeEmbed { | |||
232 | 246 | ||
233 | authorizationHeader: () => this.http.getHeaderTokenValue(), | 247 | authorizationHeader: () => this.http.getHeaderTokenValue(), |
234 | videoFileToken: () => videoFileToken, | 248 | videoFileToken: () => videoFileToken, |
249 | videoPassword: () => this.videoPassword, | ||
250 | requiresPassword: this.requiresPassword, | ||
235 | 251 | ||
236 | onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), | 252 | onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), |
237 | 253 | ||
@@ -263,6 +279,7 @@ export class PeerTubeEmbed { | |||
263 | this.initializeApi() | 279 | this.initializeApi() |
264 | 280 | ||
265 | this.playerHTML.removePlaceholder() | 281 | this.playerHTML.removePlaceholder() |
282 | if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock() | ||
266 | 283 | ||
267 | if (this.isPlaylistEmbed()) { | 284 | if (this.isPlaylistEmbed()) { |
268 | await this.buildPlayerPlaylistUpnext() | 285 | await this.buildPlayerPlaylistUpnext() |
@@ -401,6 +418,21 @@ export class PeerTubeEmbed { | |||
401 | (this.player.el() as HTMLElement).style.pointerEvents = 'none' | 418 | (this.player.el() as HTMLElement).style.pointerEvents = 'none' |
402 | } | 419 | } |
403 | 420 | ||
421 | private async handlePasswordError (err: PeerTubeServerError) { | ||
422 | let incorrectPassword: boolean = null | ||
423 | if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false | ||
424 | else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true | ||
425 | |||
426 | if (incorrectPassword === null) return false | ||
427 | |||
428 | this.requiresPassword = true | ||
429 | this.videoPassword = await this.playerHTML.askVideoPassword({ | ||
430 | incorrectPassword, | ||
431 | translations: await this.translationsPromise | ||
432 | }) | ||
433 | return true | ||
434 | } | ||
435 | |||
404 | } | 436 | } |
405 | 437 | ||
406 | PeerTubeEmbed.main() | 438 | PeerTubeEmbed.main() |
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts index 95e3b029e..c1e9f7750 100644 --- a/client/src/standalone/videos/shared/auth-http.ts +++ b/client/src/standalone/videos/shared/auth-http.ts | |||
@@ -18,10 +18,12 @@ export class AuthHTTP { | |||
18 | if (this.userOAuthTokens) this.setHeadersFromTokens() | 18 | if (this.userOAuthTokens) this.setHeadersFromTokens() |
19 | } | 19 | } |
20 | 20 | ||
21 | fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { | 21 | fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) { |
22 | const refreshFetchOptions = optionalAuth | 22 | let refreshFetchOptions: { headers?: Headers } = {} |
23 | ? { headers: this.headers } | 23 | |
24 | : {} | 24 | if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword) |
25 | |||
26 | if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers } | ||
25 | 27 | ||
26 | return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) | 28 | return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) |
27 | } | 29 | } |
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts index d93678c10..a0846d9d7 100644 --- a/client/src/standalone/videos/shared/player-html.ts +++ b/client/src/standalone/videos/shared/player-html.ts | |||
@@ -55,6 +55,58 @@ export class PlayerHTML { | |||
55 | this.wrapperElement.style.display = 'none' | 55 | this.wrapperElement.style.display = 'none' |
56 | } | 56 | } |
57 | 57 | ||
58 | async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> { | ||
59 | const { incorrectPassword, translations } = options | ||
60 | return new Promise((resolve) => { | ||
61 | |||
62 | this.removePlaceholder() | ||
63 | this.wrapperElement.style.display = 'none' | ||
64 | |||
65 | const translatedTitle = peertubeTranslate('This video is password protected', translations) | ||
66 | const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations) | ||
67 | |||
68 | document.title = translatedTitle | ||
69 | |||
70 | const videoPasswordBlock = document.getElementById('video-password-block') | ||
71 | videoPasswordBlock.style.display = 'flex' | ||
72 | |||
73 | const videoPasswordTitle = document.getElementById('video-password-title') | ||
74 | videoPasswordTitle.innerHTML = translatedTitle | ||
75 | |||
76 | const videoPasswordMessage = document.getElementById('video-password-content') | ||
77 | videoPasswordMessage.innerHTML = translatedMessage | ||
78 | |||
79 | if (incorrectPassword) { | ||
80 | const videoPasswordError = document.getElementById('video-password-error') | ||
81 | videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations) | ||
82 | videoPasswordError.style.transform = 'scale(1.2)' | ||
83 | |||
84 | setTimeout(() => { | ||
85 | videoPasswordError.style.transform = 'scale(1)' | ||
86 | }, 500) | ||
87 | } | ||
88 | |||
89 | const videoPasswordSubmitButton = document.getElementById('video-password-submit') | ||
90 | videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations) | ||
91 | |||
92 | const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement | ||
93 | videoPasswordInput.placeholder = peertubeTranslate('Password', translations) | ||
94 | |||
95 | const videoPasswordForm = document.getElementById('video-password-form') | ||
96 | videoPasswordForm.addEventListener('submit', (event) => { | ||
97 | event.preventDefault() | ||
98 | const videoPassword = videoPasswordInput.value | ||
99 | resolve(videoPassword) | ||
100 | }) | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | removeVideoPasswordBlock () { | ||
105 | const videoPasswordBlock = document.getElementById('video-password-block') | ||
106 | videoPasswordBlock.style.display = 'none' | ||
107 | this.wrapperElement.style.display = 'block' | ||
108 | } | ||
109 | |||
58 | buildPlaceholder (video: VideoDetails) { | 110 | buildPlaceholder (video: VideoDetails) { |
59 | const placeholder = this.getPlaceholderElement() | 111 | const placeholder = this.getPlaceholderElement() |
60 | 112 | ||
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts index 43ae22a3b..587516410 100644 --- a/client/src/standalone/videos/shared/player-manager-options.ts +++ b/client/src/standalone/videos/shared/player-manager-options.ts | |||
@@ -18,7 +18,7 @@ import { | |||
18 | logger, | 18 | logger, |
19 | peertubeLocalStorage, | 19 | peertubeLocalStorage, |
20 | UserLocalStorageKeys, | 20 | UserLocalStorageKeys, |
21 | videoRequiresAuth | 21 | videoRequiresUserAuth |
22 | } from '../../../root-helpers' | 22 | } from '../../../root-helpers' |
23 | import { PeerTubePlugin } from './peertube-plugin' | 23 | import { PeerTubePlugin } from './peertube-plugin' |
24 | import { PlayerHTML } from './player-html' | 24 | import { PlayerHTML } from './player-html' |
@@ -162,6 +162,9 @@ export class PlayerManagerOptions { | |||
162 | authorizationHeader: () => string | 162 | authorizationHeader: () => string |
163 | videoFileToken: () => string | 163 | videoFileToken: () => string |
164 | 164 | ||
165 | videoPassword: () => string | ||
166 | requiresPassword: boolean | ||
167 | |||
165 | serverConfig: HTMLServerConfig | 168 | serverConfig: HTMLServerConfig |
166 | 169 | ||
167 | autoplayFromPreviousVideo: boolean | 170 | autoplayFromPreviousVideo: boolean |
@@ -178,6 +181,8 @@ export class PlayerManagerOptions { | |||
178 | captionsResponse, | 181 | captionsResponse, |
179 | autoplayFromPreviousVideo, | 182 | autoplayFromPreviousVideo, |
180 | videoFileToken, | 183 | videoFileToken, |
184 | videoPassword, | ||
185 | requiresPassword, | ||
181 | translations, | 186 | translations, |
182 | forceAutoplay, | 187 | forceAutoplay, |
183 | playlistTracker, | 188 | playlistTracker, |
@@ -242,10 +247,13 @@ export class PlayerManagerOptions { | |||
242 | embedUrl: window.location.origin + video.embedPath, | 247 | embedUrl: window.location.origin + video.embedPath, |
243 | embedTitle: video.name, | 248 | embedTitle: video.name, |
244 | 249 | ||
245 | requiresAuth: videoRequiresAuth(video), | 250 | requiresUserAuth: videoRequiresUserAuth(video), |
246 | authorizationHeader, | 251 | authorizationHeader, |
247 | videoFileToken, | 252 | videoFileToken, |
248 | 253 | ||
254 | requiresPassword, | ||
255 | videoPassword, | ||
256 | |||
249 | errorNotifier: () => { | 257 | errorNotifier: () => { |
250 | // Empty, we don't have a notifier in the embed | 258 | // Empty, we don't have a notifier in the embed |
251 | }, | 259 | }, |
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index cf6d12831..76ba0a3ed 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import { PeerTubeServerError } from '../../../types' | ||
1 | import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' | 2 | import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' |
2 | import { logger } from '../../../root-helpers' | 3 | import { logger } from '../../../root-helpers' |
3 | import { AuthHTTP } from './auth-http' | 4 | import { AuthHTTP } from './auth-http' |
@@ -8,8 +9,8 @@ export class VideoFetcher { | |||
8 | 9 | ||
9 | } | 10 | } |
10 | 11 | ||
11 | async loadVideo (videoId: string) { | 12 | async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) { |
12 | const videoPromise = this.loadVideoInfo(videoId) | 13 | const videoPromise = this.loadVideoInfo({ videoId, videoPassword }) |
13 | 14 | ||
14 | let videoResponse: Response | 15 | let videoResponse: Response |
15 | let isResponseOk: boolean | 16 | let isResponseOk: boolean |
@@ -27,11 +28,14 @@ export class VideoFetcher { | |||
27 | if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { | 28 | if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { |
28 | throw new Error('This video does not exist.') | 29 | throw new Error('This video does not exist.') |
29 | } | 30 | } |
30 | 31 | if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) { | |
32 | const res = await videoResponse.json() | ||
33 | throw new PeerTubeServerError(res.message, res.code) | ||
34 | } | ||
31 | throw new Error('We cannot fetch the video. Please try again later.') | 35 | throw new Error('We cannot fetch the video. Please try again later.') |
32 | } | 36 | } |
33 | 37 | ||
34 | const captionsPromise = this.loadVideoCaptions(videoId) | 38 | const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) |
35 | 39 | ||
36 | return { captionsPromise, videoResponse } | 40 | return { captionsPromise, videoResponse } |
37 | } | 41 | } |
@@ -41,8 +45,8 @@ export class VideoFetcher { | |||
41 | .then(res => res.json() as Promise<LiveVideo>) | 45 | .then(res => res.json() as Promise<LiveVideo>) |
42 | } | 46 | } |
43 | 47 | ||
44 | loadVideoToken (video: VideoDetails) { | 48 | loadVideoToken (video: VideoDetails, videoPassword?: string) { |
45 | return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) | 49 | return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword) |
46 | .then(res => res.json() as Promise<VideoToken>) | 50 | .then(res => res.json() as Promise<VideoToken>) |
47 | .then(token => token.files.token) | 51 | .then(token => token.files.token) |
48 | } | 52 | } |
@@ -51,12 +55,12 @@ export class VideoFetcher { | |||
51 | return this.getVideoUrl(videoUUID) + '/views' | 55 | return this.getVideoUrl(videoUUID) + '/views' |
52 | } | 56 | } |
53 | 57 | ||
54 | private loadVideoInfo (videoId: string): Promise<Response> { | 58 | private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> { |
55 | return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) | 59 | return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword) |
56 | } | 60 | } |
57 | 61 | ||
58 | private loadVideoCaptions (videoId: string): Promise<Response> { | 62 | private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> { |
59 | return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) | 63 | return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) |
60 | } | 64 | } |
61 | 65 | ||
62 | private getVideoUrl (id: string) { | 66 | private getVideoUrl (id: string) { |
diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 5508515fd..60564496c 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | export * from './client-script.model' | 1 | export * from './client-script.model' |
2 | export * from './server-error.model' | ||
2 | export * from './job-state-client.type' | 3 | export * from './job-state-client.type' |
3 | export * from './job-type-client.type' | 4 | export * from './job-type-client.type' |
4 | export * from './link.type' | 5 | export * from './link.type' |
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts new file mode 100644 index 000000000..4a57287fe --- /dev/null +++ b/client/src/types/server-error.model.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { ServerErrorCode } from '@shared/models/index' | ||
2 | |||
3 | export class PeerTubeServerError extends Error { | ||
4 | serverCode: ServerErrorCode | ||
5 | |||
6 | constructor (message: string, serverCode: ServerErrorCode) { | ||
7 | super(message) | ||
8 | this.name = 'CustomError' | ||
9 | this.serverCode = serverCode | ||
10 | } | ||
11 | } | ||
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 72136614c..d03d0fe83 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts | |||
@@ -69,7 +69,10 @@ const playerKeys = { | |||
69 | '{1} from servers · {2} from peers': '{1} from servers · {2} from peers', | 69 | '{1} from servers · {2} from peers': '{1} from servers · {2} from peers', |
70 | 'Previous video': 'Previous video', | 70 | 'Previous video': 'Previous video', |
71 | 'Video page (new window)': 'Video page (new window)', | 71 | 'Video page (new window)': 'Video page (new window)', |
72 | 'Next video': 'Next video' | 72 | 'Next video': 'Next video', |
73 | 'This video is password protected': 'This video is password protected', | ||
74 | 'You need a password to watch this video.': 'You need a password to watch this video.', | ||
75 | 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password' | ||
73 | } | 76 | } |
74 | Object.assign(playerKeys, videojs) | 77 | Object.assign(playerKeys, videojs) |
75 | 78 | ||
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index 6a50aaf4e..b8016140e 100644 --- a/server/controllers/api/videos/import.ts +++ b/server/controllers/api/videos/import.ts | |||
@@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response, | |||
120 | videoChannel: res.locals.videoChannel, | 120 | videoChannel: res.locals.videoChannel, |
121 | tags: body.tags || undefined, | 121 | tags: body.tags || undefined, |
122 | user, | 122 | user, |
123 | videoPasswords: body.videoPasswords, | ||
123 | videoImportAttributes: { | 124 | videoImportAttributes: { |
124 | magnetUri, | 125 | magnetUri, |
125 | torrentName, | 126 | torrentName, |
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index a34325e79..d0eecf812 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding' | |||
47 | import { updateRouter } from './update' | 47 | import { updateRouter } from './update' |
48 | import { uploadRouter } from './upload' | 48 | import { uploadRouter } from './upload' |
49 | import { viewRouter } from './view' | 49 | import { viewRouter } from './view' |
50 | import { videoPasswordRouter } from './passwords' | ||
50 | 51 | ||
51 | const auditLogger = auditLoggerFactory('videos') | 52 | const auditLogger = auditLoggerFactory('videos') |
52 | const videosRouter = express.Router() | 53 | const videosRouter = express.Router() |
@@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter) | |||
68 | videosRouter.use('/', filesRouter) | 69 | videosRouter.use('/', filesRouter) |
69 | videosRouter.use('/', transcodingRouter) | 70 | videosRouter.use('/', transcodingRouter) |
70 | videosRouter.use('/', tokenRouter) | 71 | videosRouter.use('/', tokenRouter) |
72 | videosRouter.use('/', videoPasswordRouter) | ||
71 | 73 | ||
72 | videosRouter.get('/categories', | 74 | videosRouter.get('/categories', |
73 | openapiOperationDoc({ operationId: 'getCategories' }), | 75 | openapiOperationDoc({ operationId: 'getCategories' }), |
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts index de047d4ec..cf82c9791 100644 --- a/server/controllers/api/videos/live.ts +++ b/server/controllers/api/videos/live.ts | |||
@@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live' | |||
18 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | 18 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' |
19 | import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' | 19 | import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' |
20 | import { buildUUID, uuidToShort } from '@shared/extra-utils' | 20 | import { buildUUID, uuidToShort } from '@shared/extra-utils' |
21 | import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' | 21 | import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models' |
22 | import { logger } from '../../../helpers/logger' | 22 | import { logger } from '../../../helpers/logger' |
23 | import { sequelizeTypescript } from '../../../initializers/database' | 23 | import { sequelizeTypescript } from '../../../initializers/database' |
24 | import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' | 24 | import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' |
25 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' | 25 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' |
26 | import { VideoModel } from '../../../models/video/video' | 26 | import { VideoModel } from '../../../models/video/video' |
27 | import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' | 27 | import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' |
28 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
28 | 29 | ||
29 | const liveRouter = express.Router() | 30 | const liveRouter = express.Router() |
30 | 31 | ||
@@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) { | |||
202 | 203 | ||
203 | await federateVideoIfNeeded(videoCreated, true, t) | 204 | await federateVideoIfNeeded(videoCreated, true, t) |
204 | 205 | ||
206 | if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
207 | await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t) | ||
208 | } | ||
209 | |||
205 | logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) | 210 | logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) |
206 | 211 | ||
207 | return { videoCreated } | 212 | return { videoCreated } |
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts new file mode 100644 index 000000000..d11cf5bcc --- /dev/null +++ b/server/controllers/api/videos/passwords.ts | |||
@@ -0,0 +1,105 @@ | |||
1 | import express from 'express' | ||
2 | |||
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
4 | import { getFormattedObjects } from '../../../helpers/utils' | ||
5 | import { | ||
6 | asyncMiddleware, | ||
7 | asyncRetryTransactionMiddleware, | ||
8 | authenticate, | ||
9 | setDefaultPagination, | ||
10 | setDefaultSort | ||
11 | } from '../../../middlewares' | ||
12 | import { | ||
13 | listVideoPasswordValidator, | ||
14 | paginationValidator, | ||
15 | removeVideoPasswordValidator, | ||
16 | updateVideoPasswordListValidator, | ||
17 | videoPasswordsSortValidator | ||
18 | } from '../../../middlewares/validators' | ||
19 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
20 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
21 | import { Transaction } from 'sequelize' | ||
22 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
23 | |||
24 | const lTags = loggerTagsFactory('api', 'video') | ||
25 | const videoPasswordRouter = express.Router() | ||
26 | |||
27 | videoPasswordRouter.get('/:videoId/passwords', | ||
28 | authenticate, | ||
29 | paginationValidator, | ||
30 | videoPasswordsSortValidator, | ||
31 | setDefaultSort, | ||
32 | setDefaultPagination, | ||
33 | asyncMiddleware(listVideoPasswordValidator), | ||
34 | asyncMiddleware(listVideoPasswords) | ||
35 | ) | ||
36 | |||
37 | videoPasswordRouter.put('/:videoId/passwords', | ||
38 | authenticate, | ||
39 | asyncMiddleware(updateVideoPasswordListValidator), | ||
40 | asyncMiddleware(updateVideoPasswordList) | ||
41 | ) | ||
42 | |||
43 | videoPasswordRouter.delete('/:videoId/passwords/:passwordId', | ||
44 | authenticate, | ||
45 | asyncMiddleware(removeVideoPasswordValidator), | ||
46 | asyncRetryTransactionMiddleware(removeVideoPassword) | ||
47 | ) | ||
48 | |||
49 | // --------------------------------------------------------------------------- | ||
50 | |||
51 | export { | ||
52 | videoPasswordRouter | ||
53 | } | ||
54 | |||
55 | // --------------------------------------------------------------------------- | ||
56 | |||
57 | async function listVideoPasswords (req: express.Request, res: express.Response) { | ||
58 | const options = { | ||
59 | videoId: res.locals.videoAll.id, | ||
60 | start: req.query.start, | ||
61 | count: req.query.count, | ||
62 | sort: req.query.sort | ||
63 | } | ||
64 | |||
65 | const resultList = await VideoPasswordModel.listPasswords(options) | ||
66 | |||
67 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | ||
68 | } | ||
69 | |||
70 | async function updateVideoPasswordList (req: express.Request, res: express.Response) { | ||
71 | const videoInstance = getVideoWithAttributes(res) | ||
72 | const videoId = videoInstance.id | ||
73 | |||
74 | const passwordArray = req.body.passwords as string[] | ||
75 | |||
76 | await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => { | ||
77 | await VideoPasswordModel.deleteAllPasswords(videoId, t) | ||
78 | await VideoPasswordModel.addPasswords(passwordArray, videoId, t) | ||
79 | }) | ||
80 | |||
81 | logger.info( | ||
82 | `Video passwords for video with name %s and uuid %s have been updated`, | ||
83 | videoInstance.name, | ||
84 | videoInstance.uuid, | ||
85 | lTags(videoInstance.uuid) | ||
86 | ) | ||
87 | |||
88 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
89 | } | ||
90 | |||
91 | async function removeVideoPassword (req: express.Request, res: express.Response) { | ||
92 | const videoInstance = getVideoWithAttributes(res) | ||
93 | const password = res.locals.videoPassword | ||
94 | |||
95 | await VideoPasswordModel.deletePassword(password.id) | ||
96 | logger.info( | ||
97 | 'Password with id %d of video named %s and uuid %s has been deleted.', | ||
98 | password.id, | ||
99 | videoInstance.name, | ||
100 | videoInstance.uuid, | ||
101 | lTags(videoInstance.uuid) | ||
102 | ) | ||
103 | |||
104 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
105 | } | ||
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts index 22387c3e8..e961ffd9e 100644 --- a/server/controllers/api/videos/token.ts +++ b/server/controllers/api/videos/token.ts | |||
@@ -1,13 +1,14 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { VideoTokensManager } from '@server/lib/video-tokens-manager' | 2 | import { VideoTokensManager } from '@server/lib/video-tokens-manager' |
3 | import { VideoToken } from '@shared/models' | 3 | import { VideoPrivacy, VideoToken } from '@shared/models' |
4 | import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' | 4 | import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares' |
5 | 5 | ||
6 | const tokenRouter = express.Router() | 6 | const tokenRouter = express.Router() |
7 | 7 | ||
8 | tokenRouter.post('/:id/token', | 8 | tokenRouter.post('/:id/token', |
9 | authenticate, | 9 | optionalAuthenticate, |
10 | asyncMiddleware(videosCustomGetValidator('only-video')), | 10 | asyncMiddleware(videosCustomGetValidator('only-video')), |
11 | videoFileTokenValidator, | ||
11 | generateToken | 12 | generateToken |
12 | ) | 13 | ) |
13 | 14 | ||
@@ -22,12 +23,11 @@ export { | |||
22 | function generateToken (req: express.Request, res: express.Response) { | 23 | function generateToken (req: express.Request, res: express.Response) { |
23 | const video = res.locals.onlyVideo | 24 | const video = res.locals.onlyVideo |
24 | 25 | ||
25 | const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) | 26 | const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED |
27 | ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid }) | ||
28 | : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) | ||
26 | 29 | ||
27 | return res.json({ | 30 | return res.json({ |
28 | files: { | 31 | files |
29 | token, | ||
30 | expires | ||
31 | } | ||
32 | } as VideoToken) | 32 | } as VideoToken) |
33 | } | 33 | } |
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts index ddab428d4..28ec2cf37 100644 --- a/server/controllers/api/videos/update.ts +++ b/server/controllers/api/videos/update.ts | |||
@@ -2,13 +2,12 @@ import express from 'express' | |||
2 | import { Transaction } from 'sequelize/types' | 2 | import { Transaction } from 'sequelize/types' |
3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | 3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' |
4 | import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | 4 | import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' |
5 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
6 | import { setVideoPrivacy } from '@server/lib/video-privacy' | 5 | import { setVideoPrivacy } from '@server/lib/video-privacy' |
7 | import { openapiOperationDoc } from '@server/middlewares/doc' | 6 | import { openapiOperationDoc } from '@server/middlewares/doc' |
8 | import { FilteredModelAttributes } from '@server/types' | 7 | import { FilteredModelAttributes } from '@server/types' |
9 | import { MVideoFullLight } from '@server/types/models' | 8 | import { MVideoFullLight } from '@server/types/models' |
10 | import { forceNumber } from '@shared/core-utils' | 9 | import { forceNumber } from '@shared/core-utils' |
11 | import { HttpStatusCode, VideoUpdate } from '@shared/models' | 10 | import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models' |
12 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 11 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
13 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | 12 | import { resetSequelizeInstance } from '../../../helpers/database-utils' |
14 | import { createReqFiles } from '../../../helpers/express-utils' | 13 | import { createReqFiles } from '../../../helpers/express-utils' |
@@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | |||
20 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' | 19 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' |
21 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | 20 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' |
22 | import { VideoModel } from '../../../models/video/video' | 21 | import { VideoModel } from '../../../models/video/video' |
22 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
23 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
24 | import { exists } from '@server/helpers/custom-validators/misc' | ||
23 | 25 | ||
24 | const lTags = loggerTagsFactory('api', 'video') | 26 | const lTags = loggerTagsFactory('api', 'video') |
25 | const auditLogger = auditLoggerFactory('videos') | 27 | const auditLogger = auditLoggerFactory('videos') |
@@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: { | |||
176 | const newPrivacy = forceNumber(videoInfoToUpdate.privacy) | 178 | const newPrivacy = forceNumber(videoInfoToUpdate.privacy) |
177 | setVideoPrivacy(videoInstance, newPrivacy) | 179 | setVideoPrivacy(videoInstance, newPrivacy) |
178 | 180 | ||
181 | // Delete passwords if video is not anymore password protected | ||
182 | if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) { | ||
183 | await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction) | ||
184 | } | ||
185 | |||
186 | if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) { | ||
187 | await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction) | ||
188 | await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction) | ||
189 | } | ||
190 | |||
179 | // Unfederate the video if the new privacy is not compatible with federation | 191 | // Unfederate the video if the new privacy is not compatible with federation |
180 | if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { | 192 | if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { |
181 | await VideoModel.sendDelete(videoInstance, { transaction }) | 193 | await VideoModel.sendDelete(videoInstance, { transaction }) |
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts index 885ac8b81..073eb480f 100644 --- a/server/controllers/api/videos/upload.ts +++ b/server/controllers/api/videos/upload.ts | |||
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc' | |||
14 | import { VideoSourceModel } from '@server/models/video/video-source' | 14 | import { VideoSourceModel } from '@server/models/video/video-source' |
15 | import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' | 15 | import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' |
16 | import { uuidToShort } from '@shared/extra-utils' | 16 | import { uuidToShort } from '@shared/extra-utils' |
17 | import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models' | 17 | import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' |
18 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 18 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
19 | import { createReqFiles } from '../../../helpers/express-utils' | 19 | import { createReqFiles } from '../../../helpers/express-utils' |
20 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 20 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
@@ -33,6 +33,7 @@ import { | |||
33 | } from '../../../middlewares' | 33 | } from '../../../middlewares' |
34 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | 34 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' |
35 | import { VideoModel } from '../../../models/video/video' | 35 | import { VideoModel } from '../../../models/video/video' |
36 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
36 | 37 | ||
37 | const lTags = loggerTagsFactory('api', 'video') | 38 | const lTags = loggerTagsFactory('api', 'video') |
38 | const auditLogger = auditLoggerFactory('videos') | 39 | const auditLogger = auditLoggerFactory('videos') |
@@ -195,6 +196,10 @@ async function addVideo (options: { | |||
195 | transaction: t | 196 | transaction: t |
196 | }) | 197 | }) |
197 | 198 | ||
199 | if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
200 | await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t) | ||
201 | } | ||
202 | |||
198 | auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) | 203 | auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) |
199 | logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) | 204 | logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) |
200 | 205 | ||
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 5f75ec27c..91109217c 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { UploadFilesForCheck } from 'express' | 1 | import { Response, Request, UploadFilesForCheck } from 'express' |
2 | import { decode as magnetUriDecode } from 'magnet-uri' | 2 | import { decode as magnetUriDecode } from 'magnet-uri' |
3 | import validator from 'validator' | 3 | import validator from 'validator' |
4 | import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' | 4 | import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' |
5 | import { | 5 | import { |
6 | CONSTRAINTS_FIELDS, | 6 | CONSTRAINTS_FIELDS, |
7 | MIMETYPES, | 7 | MIMETYPES, |
@@ -13,6 +13,7 @@ import { | |||
13 | VIDEO_STATES | 13 | VIDEO_STATES |
14 | } from '../../initializers/constants' | 14 | } from '../../initializers/constants' |
15 | import { exists, isArray, isDateValid, isFileValid } from './misc' | 15 | import { exists, isArray, isDateValid, isFileValid } from './misc' |
16 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
16 | 17 | ||
17 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS | 18 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS |
18 | 19 | ||
@@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) { | |||
110 | return VIDEO_PRIVACIES[value] !== undefined | 111 | return VIDEO_PRIVACIES[value] !== undefined |
111 | } | 112 | } |
112 | 113 | ||
114 | function isVideoReplayPrivacyValid (value: number) { | ||
115 | return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED | ||
116 | } | ||
117 | |||
113 | function isScheduleVideoUpdatePrivacyValid (value: number) { | 118 | function isScheduleVideoUpdatePrivacyValid (value: number) { |
114 | return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL | 119 | return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL |
115 | } | 120 | } |
@@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) { | |||
141 | return parsed && isVideoFileInfoHashValid(parsed.infoHash) | 146 | return parsed && isVideoFileInfoHashValid(parsed.infoHash) |
142 | } | 147 | } |
143 | 148 | ||
149 | function isPasswordValid (password: string) { | ||
150 | return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min && | ||
151 | password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max | ||
152 | } | ||
153 | |||
154 | function isValidPasswordProtectedPrivacy (req: Request, res: Response) { | ||
155 | const fail = (message: string) => { | ||
156 | res.fail({ | ||
157 | status: HttpStatusCode.BAD_REQUEST_400, | ||
158 | message | ||
159 | }) | ||
160 | return false | ||
161 | } | ||
162 | |||
163 | let privacy: VideoPrivacy | ||
164 | const video = getVideoWithAttributes(res) | ||
165 | |||
166 | if (exists(req.body?.privacy)) privacy = req.body.privacy | ||
167 | else if (exists(video?.privacy)) privacy = video.privacy | ||
168 | |||
169 | if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true | ||
170 | |||
171 | if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.') | ||
172 | |||
173 | const passwords = req.body.videoPasswords || req.body.passwords | ||
174 | |||
175 | if (passwords.length === 0) return fail('At least one video password is required.') | ||
176 | |||
177 | if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.') | ||
178 | |||
179 | for (const password of passwords) { | ||
180 | if (typeof password !== 'string') { | ||
181 | return fail('Video password should be a string.') | ||
182 | } | ||
183 | |||
184 | if (!isPasswordValid(password)) { | ||
185 | return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.') | ||
186 | } | ||
187 | } | ||
188 | |||
189 | return true | ||
190 | } | ||
191 | |||
144 | // --------------------------------------------------------------------------- | 192 | // --------------------------------------------------------------------------- |
145 | 193 | ||
146 | export { | 194 | export { |
@@ -164,9 +212,12 @@ export { | |||
164 | isVideoDurationValid, | 212 | isVideoDurationValid, |
165 | isVideoTagValid, | 213 | isVideoTagValid, |
166 | isVideoPrivacyValid, | 214 | isVideoPrivacyValid, |
215 | isVideoReplayPrivacyValid, | ||
167 | isVideoFileResolutionValid, | 216 | isVideoFileResolutionValid, |
168 | isVideoFileSizeValid, | 217 | isVideoFileSizeValid, |
169 | isVideoImageValid, | 218 | isVideoImageValid, |
170 | isVideoSupportValid, | 219 | isVideoSupportValid, |
171 | isVideoFilterValid | 220 | isVideoFilterValid, |
221 | isPasswordValid, | ||
222 | isValidPasswordProtectedPrivacy | ||
172 | } | 223 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index a92fd22d6..e2f34fe16 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' | |||
27 | 27 | ||
28 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
29 | 29 | ||
30 | const LAST_MIGRATION_VERSION = 780 | 30 | const LAST_MIGRATION_VERSION = 785 |
31 | 31 | ||
32 | // --------------------------------------------------------------------------- | 32 | // --------------------------------------------------------------------------- |
33 | 33 | ||
@@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = { | |||
76 | VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], | 76 | VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], |
77 | VIDEO_COMMENTS: [ 'createdAt' ], | 77 | VIDEO_COMMENTS: [ 'createdAt' ], |
78 | 78 | ||
79 | VIDEO_PASSWORDS: [ 'createdAt' ], | ||
80 | |||
79 | VIDEO_RATES: [ 'createdAt' ], | 81 | VIDEO_RATES: [ 'createdAt' ], |
80 | BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], | 82 | BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], |
81 | 83 | ||
@@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = { | |||
444 | REASON: { min: 1, max: 5000 }, // Length | 446 | REASON: { min: 1, max: 5000 }, // Length |
445 | ERROR_MESSAGE: { min: 1, max: 5000 }, // Length | 447 | ERROR_MESSAGE: { min: 1, max: 5000 }, // Length |
446 | PROGRESS: { min: 0, max: 100 } // Value | 448 | PROGRESS: { min: 0, max: 100 } // Value |
449 | }, | ||
450 | VIDEO_PASSWORD: { | ||
451 | LENGTH: { min: 2, max: 100 } | ||
447 | } | 452 | } |
448 | } | 453 | } |
449 | 454 | ||
@@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = { | |||
520 | [VideoPrivacy.PUBLIC]: 'Public', | 525 | [VideoPrivacy.PUBLIC]: 'Public', |
521 | [VideoPrivacy.UNLISTED]: 'Unlisted', | 526 | [VideoPrivacy.UNLISTED]: 'Unlisted', |
522 | [VideoPrivacy.PRIVATE]: 'Private', | 527 | [VideoPrivacy.PRIVATE]: 'Private', |
523 | [VideoPrivacy.INTERNAL]: 'Internal' | 528 | [VideoPrivacy.INTERNAL]: 'Internal', |
529 | [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected' | ||
524 | } | 530 | } |
525 | 531 | ||
526 | const VIDEO_STATES: { [ id in VideoState ]: string } = { | 532 | 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 | |||
56 | import { VideoTagModel } from '../models/video/video-tag' | 56 | import { VideoTagModel } from '../models/video/video-tag' |
57 | import { VideoViewModel } from '../models/view/video-view' | 57 | import { VideoViewModel } from '../models/view/video-view' |
58 | import { CONFIG } from './config' | 58 | import { CONFIG } from './config' |
59 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
59 | 60 | ||
60 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 61 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string |
61 | 62 | ||
@@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) { | |||
163 | VideoJobInfoModel, | 164 | VideoJobInfoModel, |
164 | VideoChannelSyncModel, | 165 | VideoChannelSyncModel, |
165 | UserRegistrationModel, | 166 | UserRegistrationModel, |
167 | VideoPasswordModel, | ||
166 | RunnerRegistrationTokenModel, | 168 | RunnerRegistrationTokenModel, |
167 | RunnerModel, | 169 | RunnerModel, |
168 | RunnerJobModel | 170 | RunnerJobModel |
diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts new file mode 100644 index 000000000..1d85f4489 --- /dev/null +++ b/server/initializers/migrations/0785-video-password-protection.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): Promise<void> { | ||
8 | { | ||
9 | const query = ` | ||
10 | CREATE TABLE IF NOT EXISTS "videoPassword" ( | ||
11 | "id" SERIAL, | ||
12 | "password" VARCHAR(255) NOT NULL, | ||
13 | "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, | ||
14 | "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, | ||
15 | "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, | ||
16 | PRIMARY KEY ("id") | ||
17 | ); | ||
18 | ` | ||
19 | |||
20 | await utils.sequelize.query(query, { transaction : utils.transaction }) | ||
21 | } | ||
22 | } | ||
23 | |||
24 | function down (options) { | ||
25 | throw new Error('Not implemented.') | ||
26 | } | ||
27 | |||
28 | export { | ||
29 | up, | ||
30 | down | ||
31 | } | ||
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 18b16bee1..be6df1792 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts | |||
@@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity' | |||
32 | import { getBiggestActorImage } from './actor-image' | 32 | import { getBiggestActorImage } from './actor-image' |
33 | import { Hooks } from './plugins/hooks' | 33 | import { Hooks } from './plugins/hooks' |
34 | import { ServerConfigManager } from './server-config-manager' | 34 | import { ServerConfigManager } from './server-config-manager' |
35 | import { isVideoInPrivateDirectory } from './video-privacy' | ||
35 | 36 | ||
36 | type Tags = { | 37 | type Tags = { |
37 | ogType: string | 38 | ogType: string |
@@ -106,7 +107,7 @@ class ClientHtml { | |||
106 | ]) | 107 | ]) |
107 | 108 | ||
108 | // Let Angular application handle errors | 109 | // Let Angular application handle errors |
109 | if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { | 110 | if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) { |
110 | res.status(HttpStatusCode.NOT_FOUND_404) | 111 | res.status(HttpStatusCode.NOT_FOUND_404) |
111 | return html | 112 | return html |
112 | } | 113 | } |
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts index df67dc953..0ac667ba3 100644 --- a/server/lib/video-pre-import.ts +++ b/server/lib/video-pre-import.ts | |||
@@ -30,6 +30,7 @@ import { | |||
30 | import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' | 30 | import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' |
31 | import { getLocalVideoActivityPubUrl } from './activitypub/url' | 31 | import { getLocalVideoActivityPubUrl } from './activitypub/url' |
32 | import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' | 32 | import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' |
33 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
33 | 34 | ||
34 | class YoutubeDlImportError extends Error { | 35 | class YoutubeDlImportError extends Error { |
35 | code: YoutubeDlImportError.CODE | 36 | code: YoutubeDlImportError.CODE |
@@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: { | |||
64 | tags: string[] | 65 | tags: string[] |
65 | videoImportAttributes: FilteredModelAttributes<VideoImportModel> | 66 | videoImportAttributes: FilteredModelAttributes<VideoImportModel> |
66 | user: MUser | 67 | user: MUser |
68 | videoPasswords?: string[] | ||
67 | }): Promise<MVideoImportFormattable> { | 69 | }): Promise<MVideoImportFormattable> { |
68 | const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters | 70 | const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters |
69 | 71 | ||
70 | const videoImport = await sequelizeTypescript.transaction(async t => { | 72 | const videoImport = await sequelizeTypescript.transaction(async t => { |
71 | const sequelizeOptions = { transaction: t } | 73 | const sequelizeOptions = { transaction: t } |
@@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: { | |||
77 | if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) | 79 | if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) |
78 | if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) | 80 | if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) |
79 | 81 | ||
82 | if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
83 | await VideoPasswordModel.addPasswords(videoPasswords, video.id, t) | ||
84 | } | ||
85 | |||
80 | await autoBlacklistVideoIfNeeded({ | 86 | await autoBlacklistVideoIfNeeded({ |
81 | video: videoCreated, | 87 | video: videoCreated, |
82 | user, | 88 | user, |
@@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: { | |||
208 | state: VideoImportState.PENDING, | 214 | state: VideoImportState.PENDING, |
209 | userId: user.id, | 215 | userId: user.id, |
210 | videoChannelSyncId: channelSync?.id | 216 | videoChannelSyncId: channelSync?.id |
211 | } | 217 | }, |
218 | videoPasswords: importDataOverride.videoPasswords | ||
212 | }) | 219 | }) |
213 | 220 | ||
214 | // Get video subtitles | 221 | // Get video subtitles |
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts index 41f9d62b3..39430ef1e 100644 --- a/server/lib/video-privacy.ts +++ b/server/lib/video-privacy.ts | |||
@@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | |||
6 | import { VideoPrivacy, VideoStorage } from '@shared/models' | 6 | import { VideoPrivacy, VideoStorage } from '@shared/models' |
7 | import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' | 7 | import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' |
8 | 8 | ||
9 | const validPrivacySet = new Set([ | ||
10 | VideoPrivacy.PRIVATE, | ||
11 | VideoPrivacy.INTERNAL, | ||
12 | VideoPrivacy.PASSWORD_PROTECTED | ||
13 | ]) | ||
14 | |||
9 | function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { | 15 | function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { |
10 | if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { | 16 | if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { |
11 | video.publishedAt = new Date() | 17 | video.publishedAt = new Date() |
@@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { | |||
14 | video.privacy = newPrivacy | 20 | video.privacy = newPrivacy |
15 | } | 21 | } |
16 | 22 | ||
17 | function isVideoInPrivateDirectory (privacy: VideoPrivacy) { | 23 | function isVideoInPrivateDirectory (privacy) { |
18 | return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL | 24 | return validPrivacySet.has(privacy) |
19 | } | 25 | } |
20 | 26 | ||
21 | function isVideoInPublicDirectory (privacy: VideoPrivacy) { | 27 | 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 { | |||
12 | 12 | ||
13 | private static instance: VideoTokensManager | 13 | private static instance: VideoTokensManager |
14 | 14 | ||
15 | private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({ | 15 | private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({ |
16 | max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, | 16 | max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, |
17 | ttl: LRU_CACHE.VIDEO_TOKENS.TTL | 17 | ttl: LRU_CACHE.VIDEO_TOKENS.TTL |
18 | }) | 18 | }) |
19 | 19 | ||
20 | private constructor () {} | 20 | private constructor () {} |
21 | 21 | ||
22 | create (options: { | 22 | createForAuthUser (options: { |
23 | user: MUserAccountUrl | 23 | user: MUserAccountUrl |
24 | videoUUID: string | 24 | videoUUID: string |
25 | }) { | 25 | }) { |
26 | const token = buildUUID() | 26 | const { token, expires } = this.generateVideoToken() |
27 | |||
28 | const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) | ||
29 | 27 | ||
30 | this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) | 28 | this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) |
31 | 29 | ||
32 | return { token, expires } | 30 | return { token, expires } |
33 | } | 31 | } |
34 | 32 | ||
33 | createForPasswordProtectedVideo (options: { | ||
34 | videoUUID: string | ||
35 | }) { | ||
36 | const { token, expires } = this.generateVideoToken() | ||
37 | |||
38 | this.lruCache.set(token, pick(options, [ 'videoUUID' ])) | ||
39 | |||
40 | return { token, expires } | ||
41 | } | ||
42 | |||
35 | hasToken (options: { | 43 | hasToken (options: { |
36 | token: string | 44 | token: string |
37 | videoUUID: string | 45 | videoUUID: string |
@@ -54,6 +62,13 @@ class VideoTokensManager { | |||
54 | static get Instance () { | 62 | static get Instance () { |
55 | return this.instance || (this.instance = new this()) | 63 | return this.instance || (this.instance = new this()) |
56 | } | 64 | } |
65 | |||
66 | private generateVideoToken () { | ||
67 | const token = buildUUID() | ||
68 | const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) | ||
69 | |||
70 | return { token, expires } | ||
71 | } | ||
57 | } | 72 | } |
58 | 73 | ||
59 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts index 0eefa2a8e..39a7b2998 100644 --- a/server/middlewares/auth.ts +++ b/server/middlewares/auth.ts | |||
@@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner' | |||
5 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | 5 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' |
6 | import { logger } from '../helpers/logger' | 6 | import { logger } from '../helpers/logger' |
7 | import { handleOAuthAuthenticate } from '../lib/auth/oauth' | 7 | import { handleOAuthAuthenticate } from '../lib/auth/oauth' |
8 | import { ServerErrorCode } from '@shared/models' | ||
8 | 9 | ||
9 | function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { | 10 | function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { |
10 | handleOAuthAuthenticate(req, res) | 11 | handleOAuthAuthenticate(req, res) |
@@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) { | |||
48 | .catch(err => logger.error('Cannot get access token.', { err })) | 49 | .catch(err => logger.error('Cannot get access token.', { err })) |
49 | } | 50 | } |
50 | 51 | ||
51 | function authenticatePromise (req: express.Request, res: express.Response) { | 52 | function authenticatePromise (options: { |
53 | req: express.Request | ||
54 | res: express.Response | ||
55 | errorMessage?: string | ||
56 | errorStatus?: HttpStatusCode | ||
57 | errorType?: ServerErrorCode | ||
58 | }) { | ||
59 | const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options | ||
52 | return new Promise<void>(resolve => { | 60 | return new Promise<void>(resolve => { |
53 | // Already authenticated? (or tried to) | 61 | // Already authenticated? (or tried to) |
54 | if (res.locals.oauth?.token.User) return resolve() | 62 | if (res.locals.oauth?.token.User) return resolve() |
55 | 63 | ||
56 | if (res.locals.authenticated === false) { | 64 | if (res.locals.authenticated === false) { |
57 | return res.fail({ | 65 | return res.fail({ |
58 | status: HttpStatusCode.UNAUTHORIZED_401, | 66 | status: errorStatus, |
59 | message: 'Not authenticated' | 67 | type: errorType, |
68 | message: errorMessage | ||
60 | }) | 69 | }) |
61 | } | 70 | } |
62 | 71 | ||
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts index de98cd442..e5cff2dda 100644 --- a/server/middlewares/validators/shared/index.ts +++ b/server/middlewares/validators/shared/index.ts | |||
@@ -10,4 +10,5 @@ export * from './video-comments' | |||
10 | export * from './video-imports' | 10 | export * from './video-imports' |
11 | export * from './video-ownerships' | 11 | export * from './video-ownerships' |
12 | export * from './video-playlists' | 12 | export * from './video-playlists' |
13 | export * from './video-passwords' | ||
13 | export * from './videos' | 14 | 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 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
5 | import { header } from 'express-validator' | ||
6 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
7 | |||
8 | function isValidVideoPasswordHeader () { | ||
9 | return header('x-peertube-video-password') | ||
10 | .optional() | ||
11 | .isString() | ||
12 | } | ||
13 | |||
14 | function checkVideoIsPasswordProtected (res: express.Response) { | ||
15 | const video = getVideoWithAttributes(res) | ||
16 | if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) { | ||
17 | res.fail({ | ||
18 | status: HttpStatusCode.BAD_REQUEST_400, | ||
19 | message: 'Video is not password protected' | ||
20 | }) | ||
21 | return false | ||
22 | } | ||
23 | |||
24 | return true | ||
25 | } | ||
26 | |||
27 | async function doesVideoPasswordExist (idArg: number | string, res: express.Response) { | ||
28 | const video = getVideoWithAttributes(res) | ||
29 | const id = forceNumber(idArg) | ||
30 | const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id }) | ||
31 | |||
32 | if (!videoPassword) { | ||
33 | res.fail({ | ||
34 | status: HttpStatusCode.NOT_FOUND_404, | ||
35 | message: 'Video password not found' | ||
36 | }) | ||
37 | return false | ||
38 | } | ||
39 | |||
40 | res.locals.videoPassword = videoPassword | ||
41 | |||
42 | return true | ||
43 | } | ||
44 | |||
45 | async function isVideoPasswordDeletable (res: express.Response) { | ||
46 | const user = res.locals.oauth.token.User | ||
47 | const userAccount = user.Account | ||
48 | const video = res.locals.videoAll | ||
49 | |||
50 | // Check if the user who did the request is able to delete the video passwords | ||
51 | if ( | ||
52 | user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator | ||
53 | video.VideoChannel.accountId !== userAccount.id // Not the video owner | ||
54 | ) { | ||
55 | res.fail({ | ||
56 | status: HttpStatusCode.FORBIDDEN_403, | ||
57 | message: 'Cannot remove passwords of another user\'s video' | ||
58 | }) | ||
59 | return false | ||
60 | } | ||
61 | |||
62 | const passwordCount = await VideoPasswordModel.countByVideoId(video.id) | ||
63 | |||
64 | if (passwordCount <= 1) { | ||
65 | res.fail({ | ||
66 | status: HttpStatusCode.BAD_REQUEST_400, | ||
67 | message: 'Cannot delete the last password of the protected video' | ||
68 | }) | ||
69 | return false | ||
70 | } | ||
71 | |||
72 | return true | ||
73 | } | ||
74 | |||
75 | export { | ||
76 | isValidVideoPasswordHeader, | ||
77 | checkVideoIsPasswordProtected as isVideoPasswordProtected, | ||
78 | doesVideoPasswordExist, | ||
79 | isVideoPasswordDeletable | ||
80 | } | ||
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index 0033a32ff..9a7497007 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts | |||
@@ -20,6 +20,8 @@ import { | |||
20 | MVideoWithRights | 20 | MVideoWithRights |
21 | } from '@server/types/models' | 21 | } from '@server/types/models' |
22 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' | 22 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' |
23 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
24 | import { exists } from '@server/helpers/custom-validators/misc' | ||
23 | 25 | ||
24 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { | 26 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { |
25 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined | 27 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: { | |||
111 | }) { | 113 | }) { |
112 | const { req, res, video, paramId } = options | 114 | const { req, res, video, paramId } = options |
113 | 115 | ||
114 | if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { | 116 | if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) { |
115 | return checkCanSeeAuthVideo(req, res, video) | 117 | return checkCanSeeUserAuthVideo({ req, res, video }) |
118 | } | ||
119 | |||
120 | if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
121 | return checkCanSeePasswordProtectedVideo({ req, res, video }) | ||
116 | } | 122 | } |
117 | 123 | ||
118 | if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { | 124 | if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { |
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: { | |||
122 | throw new Error('Unknown video privacy when checking video right ' + video.url) | 128 | throw new Error('Unknown video privacy when checking video right ' + video.url) |
123 | } | 129 | } |
124 | 130 | ||
125 | async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { | 131 | async function checkCanSeeUserAuthVideo (options: { |
132 | req: Request | ||
133 | res: Response | ||
134 | video: MVideoId | MVideoWithRights | ||
135 | }) { | ||
136 | const { req, res, video } = options | ||
137 | |||
126 | const fail = () => { | 138 | const fail = () => { |
127 | res.fail({ | 139 | res.fail({ |
128 | status: HttpStatusCode.FORBIDDEN_403, | 140 | status: HttpStatusCode.FORBIDDEN_403, |
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
132 | return false | 144 | return false |
133 | } | 145 | } |
134 | 146 | ||
135 | await authenticatePromise(req, res) | 147 | await authenticatePromise({ req, res }) |
136 | 148 | ||
137 | const user = res.locals.oauth?.token.User | 149 | const user = res.locals.oauth?.token.User |
138 | if (!user) return fail() | 150 | if (!user) return fail() |
139 | 151 | ||
140 | const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId | 152 | const videoWithRights = await getVideoWithRights(video as MVideoWithRights) |
141 | ? video as MVideoWithRights | ||
142 | : await VideoModel.loadFull(video.id) | ||
143 | 153 | ||
144 | const privacy = videoWithRights.privacy | 154 | const privacy = videoWithRights.privacy |
145 | 155 | ||
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
148 | return true | 158 | return true |
149 | } | 159 | } |
150 | 160 | ||
151 | const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id | ||
152 | |||
153 | if (videoWithRights.isBlacklisted()) { | 161 | if (videoWithRights.isBlacklisted()) { |
154 | if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true | 162 | if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true |
155 | 163 | ||
156 | return fail() | 164 | return fail() |
157 | } | 165 | } |
158 | 166 | ||
159 | if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { | 167 | if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { |
160 | if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true | 168 | if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true |
161 | 169 | ||
162 | return fail() | 170 | return fail() |
163 | } | 171 | } |
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
166 | return fail() | 174 | return fail() |
167 | } | 175 | } |
168 | 176 | ||
177 | async function checkCanSeePasswordProtectedVideo (options: { | ||
178 | req: Request | ||
179 | res: Response | ||
180 | video: MVideo | ||
181 | }) { | ||
182 | const { req, res, video } = options | ||
183 | |||
184 | const videoWithRights = await getVideoWithRights(video as MVideoWithRights) | ||
185 | |||
186 | const videoPassword = req.header('x-peertube-video-password') | ||
187 | |||
188 | if (!exists(videoPassword)) { | ||
189 | const errorMessage = 'Please provide a password to access this password protected video' | ||
190 | const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD | ||
191 | |||
192 | if (req.header('authorization')) { | ||
193 | await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType }) | ||
194 | const user = res.locals.oauth?.token.User | ||
195 | |||
196 | if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true | ||
197 | } | ||
198 | |||
199 | res.fail({ | ||
200 | status: HttpStatusCode.FORBIDDEN_403, | ||
201 | type: errorType, | ||
202 | message: errorMessage | ||
203 | }) | ||
204 | return false | ||
205 | } | ||
206 | |||
207 | if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true | ||
208 | |||
209 | res.fail({ | ||
210 | status: HttpStatusCode.FORBIDDEN_403, | ||
211 | type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD, | ||
212 | message: 'Incorrect video password. Access to the video is denied.' | ||
213 | }) | ||
214 | |||
215 | return false | ||
216 | } | ||
217 | |||
218 | function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) { | ||
219 | const isOwnedByUser = video.VideoChannel.Account.userId === user.id | ||
220 | |||
221 | return isOwnedByUser || user.hasRight(right) | ||
222 | } | ||
223 | |||
224 | async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> { | ||
225 | return video.VideoChannel?.Account?.userId | ||
226 | ? video | ||
227 | : VideoModel.loadFull(video.id) | ||
228 | } | ||
229 | |||
169 | // --------------------------------------------------------------------------- | 230 | // --------------------------------------------------------------------------- |
170 | 231 | ||
171 | async function checkCanAccessVideoStaticFiles (options: { | 232 | async function checkCanAccessVideoStaticFiles (options: { |
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: { | |||
176 | }) { | 237 | }) { |
177 | const { video, req, res } = options | 238 | const { video, req, res } = options |
178 | 239 | ||
179 | if (res.locals.oauth?.token.User) { | 240 | if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) { |
180 | return checkCanSeeVideo(options) | 241 | return checkCanSeeVideo(options) |
181 | } | 242 | } |
182 | 243 | ||
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 959f663ac..07d6cba82 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts | |||
@@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) | |||
28 | export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) | 28 | export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) |
29 | export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) | 29 | export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) |
30 | export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) | 30 | export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) |
31 | export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS) | ||
31 | 32 | ||
32 | export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) | 33 | export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) |
33 | export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) | 34 | 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' | |||
9 | import { VideoFileModel } from '@server/models/video/video-file' | 9 | import { VideoFileModel } from '@server/models/video/video-file' |
10 | import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' | 10 | import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' |
11 | import { HttpStatusCode } from '@shared/models' | 11 | import { HttpStatusCode } from '@shared/models' |
12 | import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' | 12 | import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared' |
13 | 13 | ||
14 | type LRUValue = { | 14 | type LRUValue = { |
15 | allowed: boolean | 15 | allowed: boolean |
@@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({ | |||
25 | const ensureCanAccessVideoPrivateWebTorrentFiles = [ | 25 | const ensureCanAccessVideoPrivateWebTorrentFiles = [ |
26 | query('videoFileToken').optional().custom(exists), | 26 | query('videoFileToken').optional().custom(exists), |
27 | 27 | ||
28 | isValidVideoPasswordHeader(), | ||
29 | |||
28 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 30 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
29 | if (areValidationErrors(req, res)) return | 31 | if (areValidationErrors(req, res)) return |
30 | 32 | ||
@@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [ | |||
73 | .optional() | 75 | .optional() |
74 | .customSanitizer(isSafePeerTubeFilenameWithoutExtension), | 76 | .customSanitizer(isSafePeerTubeFilenameWithoutExtension), |
75 | 77 | ||
78 | isValidVideoPasswordHeader(), | ||
79 | |||
76 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 80 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
77 | if (areValidationErrors(req, res)) return | 81 | if (areValidationErrors(req, res)) return |
78 | 82 | ||
@@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU | |||
167 | } | 171 | } |
168 | 172 | ||
169 | function extractTokenOrDie (req: express.Request, res: express.Response) { | 173 | function extractTokenOrDie (req: express.Request, res: express.Response) { |
170 | const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken | 174 | const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken |
171 | 175 | ||
172 | if (!token) { | 176 | if (!token) { |
173 | return res.fail({ | 177 | return res.fail({ |
174 | message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', | 178 | message: 'Video password header, video file token query parameter and bearer token are all missing', // |
175 | status: HttpStatusCode.FORBIDDEN_403 | 179 | status: HttpStatusCode.FORBIDDEN_403 |
176 | }) | 180 | }) |
177 | } | 181 | } |
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts index d225dfe45..0c824c314 100644 --- a/server/middlewares/validators/videos/index.ts +++ b/server/middlewares/validators/videos/index.ts | |||
@@ -12,6 +12,8 @@ export * from './video-shares' | |||
12 | export * from './video-source' | 12 | export * from './video-source' |
13 | export * from './video-stats' | 13 | export * from './video-stats' |
14 | export * from './video-studio' | 14 | export * from './video-studio' |
15 | export * from './video-token' | ||
15 | export * from './video-transcoding' | 16 | export * from './video-transcoding' |
16 | export * from './videos' | 17 | export * from './videos' |
17 | export * from './video-channel-sync' | 18 | export * from './video-channel-sync' |
19 | 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 { | |||
10 | checkUserCanManageVideo, | 10 | checkUserCanManageVideo, |
11 | doesVideoCaptionExist, | 11 | doesVideoCaptionExist, |
12 | doesVideoExist, | 12 | doesVideoExist, |
13 | isValidVideoIdParam | 13 | isValidVideoIdParam, |
14 | isValidVideoPasswordHeader | ||
14 | } from '../shared' | 15 | } from '../shared' |
15 | 16 | ||
16 | const addVideoCaptionValidator = [ | 17 | const addVideoCaptionValidator = [ |
@@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [ | |||
62 | const listVideoCaptionsValidator = [ | 63 | const listVideoCaptionsValidator = [ |
63 | isValidVideoIdParam('videoId'), | 64 | isValidVideoIdParam('videoId'), |
64 | 65 | ||
66 | isValidVideoPasswordHeader(), | ||
67 | |||
65 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 68 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
66 | if (areValidationErrors(req, res)) return | 69 | if (areValidationErrors(req, res)) return |
67 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return | 70 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return |
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index 133feb7bd..70689b02e 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts | |||
@@ -14,7 +14,8 @@ import { | |||
14 | doesVideoCommentExist, | 14 | doesVideoCommentExist, |
15 | doesVideoCommentThreadExist, | 15 | doesVideoCommentThreadExist, |
16 | doesVideoExist, | 16 | doesVideoExist, |
17 | isValidVideoIdParam | 17 | isValidVideoIdParam, |
18 | isValidVideoPasswordHeader | ||
18 | } from '../shared' | 19 | } from '../shared' |
19 | 20 | ||
20 | const listVideoCommentsValidator = [ | 21 | const listVideoCommentsValidator = [ |
@@ -51,6 +52,7 @@ const listVideoCommentsValidator = [ | |||
51 | 52 | ||
52 | const listVideoCommentThreadsValidator = [ | 53 | const listVideoCommentThreadsValidator = [ |
53 | isValidVideoIdParam('videoId'), | 54 | isValidVideoIdParam('videoId'), |
55 | isValidVideoPasswordHeader(), | ||
54 | 56 | ||
55 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 57 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
56 | if (areValidationErrors(req, res)) return | 58 | if (areValidationErrors(req, res)) return |
@@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [ | |||
67 | 69 | ||
68 | param('threadId') | 70 | param('threadId') |
69 | .custom(isIdValid), | 71 | .custom(isIdValid), |
72 | isValidVideoPasswordHeader(), | ||
70 | 73 | ||
71 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 74 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
72 | if (areValidationErrors(req, res)) return | 75 | if (areValidationErrors(req, res)) return |
@@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [ | |||
84 | 87 | ||
85 | body('text') | 88 | body('text') |
86 | .custom(isValidVideoCommentText), | 89 | .custom(isValidVideoCommentText), |
90 | isValidVideoPasswordHeader(), | ||
87 | 91 | ||
88 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 92 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
89 | if (areValidationErrors(req, res)) return | 93 | if (areValidationErrors(req, res)) return |
@@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [ | |||
102 | isValidVideoIdParam('videoId'), | 106 | isValidVideoIdParam('videoId'), |
103 | 107 | ||
104 | param('commentId').custom(isIdValid), | 108 | param('commentId').custom(isIdValid), |
109 | isValidVideoPasswordHeader(), | ||
105 | 110 | ||
106 | body('text').custom(isValidVideoCommentText), | 111 | body('text').custom(isValidVideoCommentText), |
107 | 112 | ||
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts index 72442aeb6..a1cb65b70 100644 --- a/server/middlewares/validators/videos/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts | |||
@@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models' | |||
9 | import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' | 9 | import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' |
10 | import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' | 10 | import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' |
11 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' | 11 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' |
12 | import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' | 12 | import { |
13 | isValidPasswordProtectedPrivacy, | ||
14 | isVideoMagnetUriValid, | ||
15 | isVideoNameValid | ||
16 | } from '../../../helpers/custom-validators/videos' | ||
13 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 17 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
14 | import { logger } from '../../../helpers/logger' | 18 | import { logger } from '../../../helpers/logger' |
15 | import { CONFIG } from '../../../initializers/config' | 19 | import { CONFIG } from '../../../initializers/config' |
@@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ | |||
38 | .custom(isVideoNameValid).withMessage( | 42 | .custom(isVideoNameValid).withMessage( |
39 | `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` | 43 | `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` |
40 | ), | 44 | ), |
45 | body('videoPasswords') | ||
46 | .optional() | ||
47 | .isArray() | ||
48 | .withMessage('Video passwords should be an array.'), | ||
41 | 49 | ||
42 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 50 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
43 | const user = res.locals.oauth.token.User | 51 | const user = res.locals.oauth.token.User |
@@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ | |||
45 | 53 | ||
46 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 54 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
47 | 55 | ||
56 | if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) | ||
57 | |||
48 | if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { | 58 | if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { |
49 | cleanUpReqFiles(req) | 59 | cleanUpReqFiles(req) |
50 | 60 | ||
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index 2aff831a8..ec69a3011 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts | |||
@@ -17,7 +17,7 @@ import { | |||
17 | VideoState | 17 | VideoState |
18 | } from '@shared/models' | 18 | } from '@shared/models' |
19 | import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' | 19 | import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' |
20 | import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos' | 20 | import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos' |
21 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 21 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
22 | import { logger } from '../../../helpers/logger' | 22 | import { logger } from '../../../helpers/logger' |
23 | import { CONFIG } from '../../../initializers/config' | 23 | import { CONFIG } from '../../../initializers/config' |
@@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ | |||
69 | body('replaySettings.privacy') | 69 | body('replaySettings.privacy') |
70 | .optional() | 70 | .optional() |
71 | .customSanitizer(toIntOrNull) | 71 | .customSanitizer(toIntOrNull) |
72 | .custom(isVideoPrivacyValid), | 72 | .custom(isVideoReplayPrivacyValid), |
73 | 73 | ||
74 | body('permanentLive') | 74 | body('permanentLive') |
75 | .optional() | 75 | .optional() |
@@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ | |||
81 | .customSanitizer(toIntOrNull) | 81 | .customSanitizer(toIntOrNull) |
82 | .custom(isLiveLatencyModeValid), | 82 | .custom(isLiveLatencyModeValid), |
83 | 83 | ||
84 | body('videoPasswords') | ||
85 | .optional() | ||
86 | .isArray() | ||
87 | .withMessage('Video passwords should be an array.'), | ||
88 | |||
84 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 89 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
85 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 90 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
86 | 91 | ||
92 | if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) | ||
93 | |||
87 | if (CONFIG.LIVE.ENABLED !== true) { | 94 | if (CONFIG.LIVE.ENABLED !== true) { |
88 | cleanUpReqFiles(req) | 95 | cleanUpReqFiles(req) |
89 | 96 | ||
@@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [ | |||
170 | body('replaySettings.privacy') | 177 | body('replaySettings.privacy') |
171 | .optional() | 178 | .optional() |
172 | .customSanitizer(toIntOrNull) | 179 | .customSanitizer(toIntOrNull) |
173 | .custom(isVideoPrivacyValid), | 180 | .custom(isVideoReplayPrivacyValid), |
174 | 181 | ||
175 | body('latencyMode') | 182 | body('latencyMode') |
176 | .optional() | 183 | .optional() |
diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts new file mode 100644 index 000000000..200e496f6 --- /dev/null +++ b/server/middlewares/validators/videos/video-passwords.ts | |||
@@ -0,0 +1,77 @@ | |||
1 | import express from 'express' | ||
2 | import { | ||
3 | areValidationErrors, | ||
4 | doesVideoExist, | ||
5 | isVideoPasswordProtected, | ||
6 | isValidVideoIdParam, | ||
7 | doesVideoPasswordExist, | ||
8 | isVideoPasswordDeletable, | ||
9 | checkUserCanManageVideo | ||
10 | } from '../shared' | ||
11 | import { body, param } from 'express-validator' | ||
12 | import { isIdValid } from '@server/helpers/custom-validators/misc' | ||
13 | import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos' | ||
14 | import { UserRight } from '@shared/models' | ||
15 | |||
16 | const listVideoPasswordValidator = [ | ||
17 | isValidVideoIdParam('videoId'), | ||
18 | |||
19 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
20 | if (areValidationErrors(req, res)) return | ||
21 | |||
22 | if (!await doesVideoExist(req.params.videoId, res)) return | ||
23 | if (!isVideoPasswordProtected(res)) return | ||
24 | |||
25 | // Check if the user who did the request is able to access video password list | ||
26 | const user = res.locals.oauth.token.User | ||
27 | if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return | ||
28 | |||
29 | return next() | ||
30 | } | ||
31 | ] | ||
32 | |||
33 | const updateVideoPasswordListValidator = [ | ||
34 | body('passwords') | ||
35 | .optional() | ||
36 | .isArray() | ||
37 | .withMessage('Video passwords should be an array.'), | ||
38 | |||
39 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
40 | if (areValidationErrors(req, res)) return | ||
41 | |||
42 | if (!await doesVideoExist(req.params.videoId, res)) return | ||
43 | if (!isValidPasswordProtectedPrivacy(req, res)) return | ||
44 | |||
45 | // Check if the user who did the request is able to update video passwords | ||
46 | const user = res.locals.oauth.token.User | ||
47 | if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return | ||
48 | |||
49 | return next() | ||
50 | } | ||
51 | ] | ||
52 | |||
53 | const removeVideoPasswordValidator = [ | ||
54 | isValidVideoIdParam('videoId'), | ||
55 | |||
56 | param('passwordId') | ||
57 | .custom(isIdValid), | ||
58 | |||
59 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
60 | if (areValidationErrors(req, res)) return | ||
61 | |||
62 | if (!await doesVideoExist(req.params.videoId, res)) return | ||
63 | if (!isVideoPasswordProtected(res)) return | ||
64 | if (!await doesVideoPasswordExist(req.params.passwordId, res)) return | ||
65 | if (!await isVideoPasswordDeletable(res)) return | ||
66 | |||
67 | return next() | ||
68 | } | ||
69 | ] | ||
70 | |||
71 | // --------------------------------------------------------------------------- | ||
72 | |||
73 | export { | ||
74 | listVideoPasswordValidator, | ||
75 | updateVideoPasswordListValidator, | ||
76 | removeVideoPasswordValidator | ||
77 | } | ||
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index c631a16f8..95a5ba63a 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts | |||
@@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => { | |||
153 | } | 153 | } |
154 | 154 | ||
155 | if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { | 155 | if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { |
156 | await authenticatePromise(req, res) | 156 | await authenticatePromise({ req, res }) |
157 | 157 | ||
158 | const user = res.locals.oauth ? res.locals.oauth.token.User : null | 158 | const user = res.locals.oauth ? res.locals.oauth.token.User : null |
159 | 159 | ||
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts index 275634d5b..c837b047b 100644 --- a/server/middlewares/validators/videos/video-rates.ts +++ b/server/middlewares/validators/videos/video-rates.ts | |||
@@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc' | |||
7 | import { isRatingValid } from '../../../helpers/custom-validators/video-rates' | 7 | import { isRatingValid } from '../../../helpers/custom-validators/video-rates' |
8 | import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' | 8 | import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' |
9 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | 9 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' |
10 | import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' | 10 | import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared' |
11 | 11 | ||
12 | const videoUpdateRateValidator = [ | 12 | const videoUpdateRateValidator = [ |
13 | isValidVideoIdParam('id'), | 13 | isValidVideoIdParam('id'), |
14 | 14 | ||
15 | body('rating') | 15 | body('rating') |
16 | .custom(isVideoRatingTypeValid), | 16 | .custom(isVideoRatingTypeValid), |
17 | isValidVideoPasswordHeader(), | ||
17 | 18 | ||
18 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 19 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
19 | if (areValidationErrors(req, res)) return | 20 | if (areValidationErrors(req, res)) return |
diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts new file mode 100644 index 000000000..d4253e21d --- /dev/null +++ b/server/middlewares/validators/videos/video-token.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import express from 'express' | ||
2 | import { VideoPrivacy } from '../../../../shared/models/videos' | ||
3 | import { HttpStatusCode } from '@shared/models' | ||
4 | import { exists } from '@server/helpers/custom-validators/misc' | ||
5 | |||
6 | const videoFileTokenValidator = [ | ||
7 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
8 | const video = res.locals.onlyVideo | ||
9 | if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) { | ||
10 | return res.fail({ | ||
11 | status: HttpStatusCode.UNAUTHORIZED_401, | ||
12 | message: 'Not authenticated' | ||
13 | }) | ||
14 | } | ||
15 | |||
16 | return next() | ||
17 | } | ||
18 | ] | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | export { | ||
23 | videoFileTokenValidator | ||
24 | } | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 794e1d4f1..7f1f39b11 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../ | |||
23 | import { | 23 | import { |
24 | areVideoTagsValid, | 24 | areVideoTagsValid, |
25 | isScheduleVideoUpdatePrivacyValid, | 25 | isScheduleVideoUpdatePrivacyValid, |
26 | isValidPasswordProtectedPrivacy, | ||
26 | isVideoCategoryValid, | 27 | isVideoCategoryValid, |
27 | isVideoDescriptionValid, | 28 | isVideoDescriptionValid, |
28 | isVideoFileMimeTypeValid, | 29 | isVideoFileMimeTypeValid, |
@@ -55,7 +56,8 @@ import { | |||
55 | doesVideoChannelOfAccountExist, | 56 | doesVideoChannelOfAccountExist, |
56 | doesVideoExist, | 57 | doesVideoExist, |
57 | doesVideoFileOfVideoExist, | 58 | doesVideoFileOfVideoExist, |
58 | isValidVideoIdParam | 59 | isValidVideoIdParam, |
60 | isValidVideoPasswordHeader | ||
59 | } from '../shared' | 61 | } from '../shared' |
60 | 62 | ||
61 | const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ | 63 | const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ |
@@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ | |||
70 | body('channelId') | 72 | body('channelId') |
71 | .customSanitizer(toIntOrNull) | 73 | .customSanitizer(toIntOrNull) |
72 | .custom(isIdValid), | 74 | .custom(isIdValid), |
75 | body('videoPasswords') | ||
76 | .optional() | ||
77 | .isArray() | ||
78 | .withMessage('Video passwords should be an array.'), | ||
73 | 79 | ||
74 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 80 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
75 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 81 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
@@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ | |||
81 | return cleanUpReqFiles(req) | 87 | return cleanUpReqFiles(req) |
82 | } | 88 | } |
83 | 89 | ||
90 | if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) | ||
91 | |||
84 | try { | 92 | try { |
85 | if (!videoFile.duration) await addDurationToVideo(videoFile) | 93 | if (!videoFile.duration) await addDurationToVideo(videoFile) |
86 | } catch (err) { | 94 | } catch (err) { |
@@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ | |||
174 | body('channelId') | 182 | body('channelId') |
175 | .customSanitizer(toIntOrNull) | 183 | .customSanitizer(toIntOrNull) |
176 | .custom(isIdValid), | 184 | .custom(isIdValid), |
185 | body('videoPasswords') | ||
186 | .optional() | ||
187 | .isArray() | ||
188 | .withMessage('Video passwords should be an array.'), | ||
177 | 189 | ||
178 | header('x-upload-content-length') | 190 | header('x-upload-content-length') |
179 | .isNumeric() | 191 | .isNumeric() |
@@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ | |||
205 | const files = { videofile: [ videoFileMetadata ] } | 217 | const files = { videofile: [ videoFileMetadata ] } |
206 | if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() | 218 | if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() |
207 | 219 | ||
220 | if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup() | ||
221 | |||
208 | // multer required unsetting the Content-Type, now we can set it for node-uploadx | 222 | // multer required unsetting the Content-Type, now we can set it for node-uploadx |
209 | req.headers['content-type'] = 'application/json; charset=utf-8' | 223 | req.headers['content-type'] = 'application/json; charset=utf-8' |
210 | // place previewfile in metadata so that uploadx saves it in .META | 224 | // place previewfile in metadata so that uploadx saves it in .META |
@@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ | |||
227 | .optional() | 241 | .optional() |
228 | .customSanitizer(toIntOrNull) | 242 | .customSanitizer(toIntOrNull) |
229 | .custom(isIdValid), | 243 | .custom(isIdValid), |
244 | body('videoPasswords') | ||
245 | .optional() | ||
246 | .isArray() | ||
247 | .withMessage('Video passwords should be an array.'), | ||
230 | 248 | ||
231 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 249 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
232 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 250 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
233 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) | 251 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) |
234 | if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) | 252 | if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) |
235 | 253 | ||
254 | if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) | ||
255 | |||
236 | const video = getVideoWithAttributes(res) | 256 | const video = getVideoWithAttributes(res) |
237 | if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { | 257 | if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { |
238 | return res.fail({ message: 'Cannot update privacy of a live that has already started' }) | 258 | return res.fail({ message: 'Cannot update privacy of a live that has already started' }) |
@@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | | |||
281 | return [ | 301 | return [ |
282 | isValidVideoIdParam('id'), | 302 | isValidVideoIdParam('id'), |
283 | 303 | ||
304 | isValidVideoPasswordHeader(), | ||
305 | |||
284 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 306 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
285 | if (areValidationErrors(req, res)) return | 307 | if (areValidationErrors(req, res)) return |
286 | if (!await doesVideoExist(req.params.id, res, fetchType)) return | 308 | if (!await doesVideoExist(req.params.id, res, fetchType)) return |
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts new file mode 100644 index 000000000..648366c3b --- /dev/null +++ b/server/models/video/video-password.ts | |||
@@ -0,0 +1,137 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { VideoModel } from './video' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { ResultList, VideoPassword } from '@shared/models' | ||
5 | import { getSort, throwIfNotValid } from '../shared' | ||
6 | import { FindOptions, Transaction } from 'sequelize' | ||
7 | import { MVideoPassword } from '@server/types/models' | ||
8 | import { isPasswordValid } from '@server/helpers/custom-validators/videos' | ||
9 | import { pick } from '@shared/core-utils' | ||
10 | |||
11 | @DefaultScope(() => ({ | ||
12 | include: [ | ||
13 | { | ||
14 | model: VideoModel.unscoped(), | ||
15 | required: true | ||
16 | } | ||
17 | ] | ||
18 | })) | ||
19 | @Table({ | ||
20 | tableName: 'videoPassword', | ||
21 | indexes: [ | ||
22 | { | ||
23 | fields: [ 'videoId', 'password' ], | ||
24 | unique: true | ||
25 | } | ||
26 | ] | ||
27 | }) | ||
28 | export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> { | ||
29 | |||
30 | @AllowNull(false) | ||
31 | @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword')) | ||
32 | @Column | ||
33 | password: string | ||
34 | |||
35 | @CreatedAt | ||
36 | createdAt: Date | ||
37 | |||
38 | @UpdatedAt | ||
39 | updatedAt: Date | ||
40 | |||
41 | @ForeignKey(() => VideoModel) | ||
42 | @Column | ||
43 | videoId: number | ||
44 | |||
45 | @BelongsTo(() => VideoModel, { | ||
46 | foreignKey: { | ||
47 | allowNull: false | ||
48 | }, | ||
49 | onDelete: 'cascade' | ||
50 | }) | ||
51 | Video: VideoModel | ||
52 | |||
53 | static async countByVideoId (videoId: number, t?: Transaction) { | ||
54 | const query: FindOptions = { | ||
55 | where: { | ||
56 | videoId | ||
57 | }, | ||
58 | transaction: t | ||
59 | } | ||
60 | |||
61 | return VideoPasswordModel.count(query) | ||
62 | } | ||
63 | |||
64 | static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> { | ||
65 | const { id, videoId, t } = options | ||
66 | const query: FindOptions = { | ||
67 | where: { | ||
68 | id, | ||
69 | videoId | ||
70 | }, | ||
71 | transaction: t | ||
72 | } | ||
73 | |||
74 | return VideoPasswordModel.findOne(query) | ||
75 | } | ||
76 | |||
77 | static async listPasswords (options: { | ||
78 | start: number | ||
79 | count: number | ||
80 | sort: string | ||
81 | videoId: number | ||
82 | }): Promise<ResultList<MVideoPassword>> { | ||
83 | const { start, count, sort, videoId } = options | ||
84 | |||
85 | const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({ | ||
86 | where: { videoId }, | ||
87 | order: getSort(sort), | ||
88 | offset: start, | ||
89 | limit: count | ||
90 | }) | ||
91 | |||
92 | return { total, data } | ||
93 | } | ||
94 | |||
95 | static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> { | ||
96 | for (const password of passwords) { | ||
97 | await VideoPasswordModel.create({ | ||
98 | password, | ||
99 | videoId | ||
100 | }, { transaction }) | ||
101 | } | ||
102 | } | ||
103 | |||
104 | static async deleteAllPasswords (videoId: number, transaction?: Transaction) { | ||
105 | await VideoPasswordModel.destroy({ | ||
106 | where: { videoId }, | ||
107 | transaction | ||
108 | }) | ||
109 | } | ||
110 | |||
111 | static async deletePassword (passwordId: number, transaction?: Transaction) { | ||
112 | await VideoPasswordModel.destroy({ | ||
113 | where: { id: passwordId }, | ||
114 | transaction | ||
115 | }) | ||
116 | } | ||
117 | |||
118 | static async isACorrectPassword (options: { | ||
119 | videoId: number | ||
120 | password: string | ||
121 | }) { | ||
122 | const query = { | ||
123 | where: pick(options, [ 'videoId', 'password' ]) | ||
124 | } | ||
125 | return VideoPasswordModel.findOne(query) | ||
126 | } | ||
127 | |||
128 | toFormattedJSON (): VideoPassword { | ||
129 | return { | ||
130 | id: this.id, | ||
131 | password: this.password, | ||
132 | videoId: this.videoId, | ||
133 | createdAt: this.createdAt, | ||
134 | updatedAt: this.updatedAt | ||
135 | } | ||
136 | } | ||
137 | } | ||
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts index b832f9768..61ae6b9fe 100644 --- a/server/models/video/video-playlist-element.ts +++ b/server/models/video/video-playlist-element.ts | |||
@@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide | |||
336 | // Internal video? | 336 | // Internal video? |
337 | if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR | 337 | if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR |
338 | 338 | ||
339 | if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE | 339 | // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal) |
340 | if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) { | ||
341 | return VideoPlaylistElementType.PRIVATE | ||
342 | } | ||
340 | 343 | ||
341 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE | 344 | if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE |
342 | 345 | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 8e3af62a4..f90f2b7f6 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -136,6 +136,7 @@ import { VideoFileModel } from './video-file' | |||
136 | import { VideoImportModel } from './video-import' | 136 | import { VideoImportModel } from './video-import' |
137 | import { VideoJobInfoModel } from './video-job-info' | 137 | import { VideoJobInfoModel } from './video-job-info' |
138 | import { VideoLiveModel } from './video-live' | 138 | import { VideoLiveModel } from './video-live' |
139 | import { VideoPasswordModel } from './video-password' | ||
139 | import { VideoPlaylistElementModel } from './video-playlist-element' | 140 | import { VideoPlaylistElementModel } from './video-playlist-element' |
140 | import { VideoShareModel } from './video-share' | 141 | import { VideoShareModel } from './video-share' |
141 | import { VideoSourceModel } from './video-source' | 142 | import { VideoSourceModel } from './video-source' |
@@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
734 | }) | 735 | }) |
735 | VideoCaptions: VideoCaptionModel[] | 736 | VideoCaptions: VideoCaptionModel[] |
736 | 737 | ||
738 | @HasMany(() => VideoPasswordModel, { | ||
739 | foreignKey: { | ||
740 | name: 'videoId', | ||
741 | allowNull: false | ||
742 | }, | ||
743 | onDelete: 'cascade' | ||
744 | }) | ||
745 | VideoPasswords: VideoPasswordModel[] | ||
746 | |||
737 | @HasOne(() => VideoJobInfoModel, { | 747 | @HasOne(() => VideoJobInfoModel, { |
738 | foreignKey: { | 748 | foreignKey: { |
739 | name: 'videoId', | 749 | name: 'videoId', |
@@ -1918,7 +1928,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
1918 | 1928 | ||
1919 | // --------------------------------------------------------------------------- | 1929 | // --------------------------------------------------------------------------- |
1920 | 1930 | ||
1921 | requiresAuth (options: { | 1931 | requiresUserAuth (options: { |
1922 | urlParamId: string | 1932 | urlParamId: string |
1923 | checkBlacklist: boolean | 1933 | checkBlacklist: boolean |
1924 | }) { | 1934 | }) { |
@@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
1936 | 1946 | ||
1937 | if (checkBlacklist && this.VideoBlacklist) return true | 1947 | if (checkBlacklist && this.VideoBlacklist) return true |
1938 | 1948 | ||
1939 | if (this.privacy !== VideoPrivacy.PUBLIC) { | 1949 | if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) { |
1940 | throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) | 1950 | return false |
1941 | } | 1951 | } |
1942 | 1952 | ||
1943 | return false | 1953 | throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) |
1944 | } | 1954 | } |
1945 | 1955 | ||
1946 | hasPrivateStaticPath () { | 1956 | hasPrivateStaticPath () { |
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts index 2dc735c23..406a96824 100644 --- a/server/tests/api/check-params/live.ts +++ b/server/tests/api/check-params/live.ts | |||
@@ -143,7 +143,7 @@ describe('Test video lives API validator', function () { | |||
143 | }) | 143 | }) |
144 | 144 | ||
145 | it('Should fail with a bad privacy for replay settings', async function () { | 145 | it('Should fail with a bad privacy for replay settings', async function () { |
146 | const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } } | 146 | const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } } |
147 | 147 | ||
148 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | 148 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) |
149 | }) | 149 | }) |
@@ -472,7 +472,7 @@ describe('Test video lives API validator', function () { | |||
472 | }) | 472 | }) |
473 | 473 | ||
474 | it('Should fail with a bad privacy for replay settings', async function () { | 474 | it('Should fail with a bad privacy for replay settings', async function () { |
475 | const fields = { saveReplay: true, replaySettings: { privacy: 5 } } | 475 | const fields = { saveReplay: true, replaySettings: { privacy: 999 } } |
476 | 476 | ||
477 | await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | 477 | await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) |
478 | }) | 478 | }) |
diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts new file mode 100644 index 000000000..4e936b5d2 --- /dev/null +++ b/server/tests/api/check-params/video-passwords.ts | |||
@@ -0,0 +1,609 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | import { | ||
3 | FIXTURE_URLS, | ||
4 | checkBadCountPagination, | ||
5 | checkBadSortPagination, | ||
6 | checkBadStartPagination, | ||
7 | checkUploadVideoParam | ||
8 | } from '@server/tests/shared' | ||
9 | import { root } from '@shared/core-utils' | ||
10 | import { | ||
11 | HttpStatusCode, | ||
12 | PeerTubeProblemDocument, | ||
13 | ServerErrorCode, | ||
14 | VideoCreateResult, | ||
15 | VideoPrivacy | ||
16 | } from '@shared/models' | ||
17 | import { | ||
18 | cleanupTests, | ||
19 | createSingleServer, | ||
20 | makePostBodyRequest, | ||
21 | PeerTubeServer, | ||
22 | setAccessTokensToServers | ||
23 | } from '@shared/server-commands' | ||
24 | import { expect } from 'chai' | ||
25 | import { join } from 'path' | ||
26 | |||
27 | describe('Test video passwords validator', function () { | ||
28 | let path: string | ||
29 | let server: PeerTubeServer | ||
30 | let userAccessToken = '' | ||
31 | let video: VideoCreateResult | ||
32 | let channelId: number | ||
33 | let publicVideo: VideoCreateResult | ||
34 | let commentId: number | ||
35 | // --------------------------------------------------------------- | ||
36 | |||
37 | before(async function () { | ||
38 | this.timeout(50000) | ||
39 | |||
40 | server = await createSingleServer(1) | ||
41 | |||
42 | await setAccessTokensToServers([ server ]) | ||
43 | |||
44 | await server.config.updateCustomSubConfig({ | ||
45 | newConfig: { | ||
46 | live: { | ||
47 | enabled: true, | ||
48 | latencySetting: { | ||
49 | enabled: false | ||
50 | }, | ||
51 | allowReplay: false | ||
52 | }, | ||
53 | import: { | ||
54 | videos: { | ||
55 | http:{ | ||
56 | enabled: true | ||
57 | } | ||
58 | } | ||
59 | } | ||
60 | } | ||
61 | }) | ||
62 | |||
63 | userAccessToken = await server.users.generateUserAndToken('user1') | ||
64 | |||
65 | { | ||
66 | const body = await server.users.getMyInfo() | ||
67 | channelId = body.videoChannels[0].id | ||
68 | } | ||
69 | |||
70 | { | ||
71 | video = await server.videos.quickUpload({ | ||
72 | name: 'password protected video', | ||
73 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
74 | videoPasswords: [ 'password1', 'password2' ] | ||
75 | }) | ||
76 | } | ||
77 | path = '/api/v1/videos/' | ||
78 | }) | ||
79 | |||
80 | async function checkVideoPasswordOptions (options: { | ||
81 | server: PeerTubeServer | ||
82 | token: string | ||
83 | videoPasswords: string[] | ||
84 | expectedStatus: HttpStatusCode | ||
85 | mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live' | ||
86 | }) { | ||
87 | const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options | ||
88 | const attaches = { | ||
89 | fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm') | ||
90 | } | ||
91 | const baseCorrectParams = { | ||
92 | name: 'my super name', | ||
93 | category: 5, | ||
94 | licence: 1, | ||
95 | language: 'pt', | ||
96 | nsfw: false, | ||
97 | commentsEnabled: true, | ||
98 | downloadEnabled: true, | ||
99 | waitTranscoding: true, | ||
100 | description: 'my super description', | ||
101 | support: 'my super support text', | ||
102 | tags: [ 'tag1', 'tag2' ], | ||
103 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
104 | channelId, | ||
105 | originallyPublishedAt: new Date().toISOString() | ||
106 | } | ||
107 | if (mode === 'uploadLegacy') { | ||
108 | const fields = { ...baseCorrectParams, videoPasswords } | ||
109 | return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy') | ||
110 | } | ||
111 | |||
112 | if (mode === 'uploadResumable') { | ||
113 | const fields = { ...baseCorrectParams, videoPasswords } | ||
114 | return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable') | ||
115 | } | ||
116 | |||
117 | if (mode === 'import') { | ||
118 | const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords } | ||
119 | return server.imports.importVideo({ attributes, expectedStatus }) | ||
120 | } | ||
121 | |||
122 | if (mode === 'updateVideo') { | ||
123 | const attributes = { ...baseCorrectParams, videoPasswords } | ||
124 | return server.videos.update({ token, expectedStatus, id: video.id, attributes }) | ||
125 | } | ||
126 | |||
127 | if (mode === 'updatePasswords') { | ||
128 | return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords }) | ||
129 | } | ||
130 | |||
131 | if (mode === 'live') { | ||
132 | const fields = { ...baseCorrectParams, videoPasswords } | ||
133 | |||
134 | return server.live.create({ fields, expectedStatus }) | ||
135 | } | ||
136 | } | ||
137 | |||
138 | function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') { | ||
139 | |||
140 | it('Should fail with a password protected privacy without providing a password', async function () { | ||
141 | await checkVideoPasswordOptions({ | ||
142 | server, | ||
143 | token: server.accessToken, | ||
144 | videoPasswords: undefined, | ||
145 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
146 | mode | ||
147 | }) | ||
148 | }) | ||
149 | |||
150 | it('Should fail with a password protected privacy and an empty password list', async function () { | ||
151 | const videoPasswords = [] | ||
152 | |||
153 | await checkVideoPasswordOptions({ | ||
154 | server, | ||
155 | token: server.accessToken, | ||
156 | videoPasswords, | ||
157 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
158 | mode | ||
159 | }) | ||
160 | }) | ||
161 | |||
162 | it('Should fail with a password protected privacy and a too short password', async function () { | ||
163 | const videoPasswords = [ 'p' ] | ||
164 | |||
165 | await checkVideoPasswordOptions({ | ||
166 | server, | ||
167 | token: server.accessToken, | ||
168 | videoPasswords, | ||
169 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
170 | mode | ||
171 | }) | ||
172 | }) | ||
173 | |||
174 | it('Should fail with a password protected privacy and a too long password', async function () { | ||
175 | const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ] | ||
176 | |||
177 | await checkVideoPasswordOptions({ | ||
178 | server, | ||
179 | token: server.accessToken, | ||
180 | videoPasswords, | ||
181 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
182 | mode | ||
183 | }) | ||
184 | }) | ||
185 | |||
186 | it('Should fail with a password protected privacy and an empty password', async function () { | ||
187 | const videoPasswords = [ '' ] | ||
188 | |||
189 | await checkVideoPasswordOptions({ | ||
190 | server, | ||
191 | token: server.accessToken, | ||
192 | videoPasswords, | ||
193 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
194 | mode | ||
195 | }) | ||
196 | }) | ||
197 | |||
198 | it('Should fail with a password protected privacy and duplicated passwords', async function () { | ||
199 | const videoPasswords = [ 'password', 'password' ] | ||
200 | |||
201 | await checkVideoPasswordOptions({ | ||
202 | server, | ||
203 | token: server.accessToken, | ||
204 | videoPasswords, | ||
205 | expectedStatus: HttpStatusCode.BAD_REQUEST_400, | ||
206 | mode | ||
207 | }) | ||
208 | }) | ||
209 | |||
210 | if (mode === 'updatePasswords') { | ||
211 | it('Should fail for an unauthenticated user', async function () { | ||
212 | const videoPasswords = [ 'password' ] | ||
213 | await checkVideoPasswordOptions({ | ||
214 | server, | ||
215 | token: null, | ||
216 | videoPasswords, | ||
217 | expectedStatus: HttpStatusCode.UNAUTHORIZED_401, | ||
218 | mode | ||
219 | }) | ||
220 | }) | ||
221 | |||
222 | it('Should fail for an unauthorized user', async function () { | ||
223 | const videoPasswords = [ 'password' ] | ||
224 | await checkVideoPasswordOptions({ | ||
225 | server, | ||
226 | token: userAccessToken, | ||
227 | videoPasswords, | ||
228 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
229 | mode | ||
230 | }) | ||
231 | }) | ||
232 | } | ||
233 | |||
234 | it('Should succeed with a password protected privacy and correct passwords', async function () { | ||
235 | const videoPasswords = [ 'password1', 'password2' ] | ||
236 | const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo' | ||
237 | ? HttpStatusCode.NO_CONTENT_204 | ||
238 | : HttpStatusCode.OK_200 | ||
239 | |||
240 | await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode }) | ||
241 | }) | ||
242 | } | ||
243 | |||
244 | describe('When adding or updating a video', function () { | ||
245 | describe('Resumable upload', function () { | ||
246 | validateVideoPasswordList('uploadResumable') | ||
247 | }) | ||
248 | |||
249 | describe('Legacy upload', function () { | ||
250 | validateVideoPasswordList('uploadLegacy') | ||
251 | }) | ||
252 | |||
253 | describe('When importing a video', function () { | ||
254 | validateVideoPasswordList('import') | ||
255 | }) | ||
256 | |||
257 | describe('When updating a video', function () { | ||
258 | validateVideoPasswordList('updateVideo') | ||
259 | }) | ||
260 | |||
261 | describe('When updating the password list of a video', function () { | ||
262 | validateVideoPasswordList('updatePasswords') | ||
263 | }) | ||
264 | |||
265 | describe('When creating a live', function () { | ||
266 | validateVideoPasswordList('live') | ||
267 | }) | ||
268 | }) | ||
269 | |||
270 | async function checkVideoAccessOptions (options: { | ||
271 | server: PeerTubeServer | ||
272 | token?: string | ||
273 | videoPassword?: string | ||
274 | expectedStatus: HttpStatusCode | ||
275 | mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token' | ||
276 | }) { | ||
277 | const { server, token = null, videoPassword, expectedStatus, mode } = options | ||
278 | |||
279 | if (mode === 'get') { | ||
280 | return server.videos.get({ id: video.id, expectedStatus }) | ||
281 | } | ||
282 | |||
283 | if (mode === 'getWithToken') { | ||
284 | return server.videos.getWithToken({ | ||
285 | id: video.id, | ||
286 | token, | ||
287 | expectedStatus | ||
288 | }) | ||
289 | } | ||
290 | |||
291 | if (mode === 'getWithPassword') { | ||
292 | return server.videos.getWithPassword({ | ||
293 | id: video.id, | ||
294 | token, | ||
295 | expectedStatus, | ||
296 | password: videoPassword | ||
297 | }) | ||
298 | } | ||
299 | |||
300 | if (mode === 'rate') { | ||
301 | return server.videos.rate({ | ||
302 | id: video.id, | ||
303 | token, | ||
304 | expectedStatus, | ||
305 | rating: 'like', | ||
306 | videoPassword | ||
307 | }) | ||
308 | } | ||
309 | |||
310 | if (mode === 'createThread') { | ||
311 | const fields = { text: 'super comment' } | ||
312 | const headers = videoPassword !== undefined && videoPassword !== null | ||
313 | ? { 'x-peertube-video-password': videoPassword } | ||
314 | : undefined | ||
315 | const body = await makePostBodyRequest({ | ||
316 | url: server.url, | ||
317 | path: path + video.uuid + '/comment-threads', | ||
318 | token, | ||
319 | fields, | ||
320 | headers, | ||
321 | expectedStatus | ||
322 | }) | ||
323 | return JSON.parse(body.text) | ||
324 | } | ||
325 | |||
326 | if (mode === 'replyThread') { | ||
327 | const fields = { text: 'super reply' } | ||
328 | const headers = videoPassword !== undefined && videoPassword !== null | ||
329 | ? { 'x-peertube-video-password': videoPassword } | ||
330 | : undefined | ||
331 | return makePostBodyRequest({ | ||
332 | url: server.url, | ||
333 | path: path + video.uuid + '/comments/' + commentId, | ||
334 | token, | ||
335 | fields, | ||
336 | headers, | ||
337 | expectedStatus | ||
338 | }) | ||
339 | } | ||
340 | if (mode === 'listThreads') { | ||
341 | return server.comments.listThreads({ | ||
342 | videoId: video.id, | ||
343 | token, | ||
344 | expectedStatus, | ||
345 | videoPassword | ||
346 | }) | ||
347 | } | ||
348 | |||
349 | if (mode === 'listCaptions') { | ||
350 | return server.captions.list({ | ||
351 | videoId: video.id, | ||
352 | token, | ||
353 | expectedStatus, | ||
354 | videoPassword | ||
355 | }) | ||
356 | } | ||
357 | |||
358 | if (mode === 'token') { | ||
359 | return server.videoToken.create({ | ||
360 | videoId: video.id, | ||
361 | token, | ||
362 | expectedStatus, | ||
363 | videoPassword | ||
364 | }) | ||
365 | } | ||
366 | } | ||
367 | |||
368 | function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') { | ||
369 | const serverCode = mode === 'providePassword' | ||
370 | ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD | ||
371 | : ServerErrorCode.INCORRECT_VIDEO_PASSWORD | ||
372 | |||
373 | const message = mode === 'providePassword' | ||
374 | ? 'Please provide a password to access this password protected video' | ||
375 | : 'Incorrect video password. Access to the video is denied.' | ||
376 | |||
377 | if (!error.code) { | ||
378 | error = JSON.parse(error.text) | ||
379 | } | ||
380 | |||
381 | expect(error.code).to.equal(serverCode) | ||
382 | expect(error.detail).to.equal(message) | ||
383 | expect(error.error).to.equal(message) | ||
384 | |||
385 | expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403) | ||
386 | } | ||
387 | |||
388 | function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') { | ||
389 | const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode) | ||
390 | let tokens: string[] | ||
391 | if (!requiresUserAuth) { | ||
392 | it('Should fail without providing a password for an unlogged user', async function () { | ||
393 | const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode }) | ||
394 | const error = body as unknown as PeerTubeProblemDocument | ||
395 | |||
396 | checkVideoError(error, 'providePassword') | ||
397 | }) | ||
398 | } | ||
399 | |||
400 | it('Should fail without providing a password for an unauthorised user', async function () { | ||
401 | const tmp = mode === 'get' ? 'getWithToken' : mode | ||
402 | |||
403 | const body = await checkVideoAccessOptions({ | ||
404 | server, | ||
405 | token: userAccessToken, | ||
406 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
407 | mode: tmp | ||
408 | }) | ||
409 | |||
410 | const error = body as unknown as PeerTubeProblemDocument | ||
411 | |||
412 | checkVideoError(error, 'providePassword') | ||
413 | }) | ||
414 | |||
415 | it('Should fail if a wrong password is entered', async function () { | ||
416 | const tmp = mode === 'get' ? 'getWithPassword' : mode | ||
417 | tokens = [ userAccessToken, server.accessToken ] | ||
418 | |||
419 | if (!requiresUserAuth) tokens.push(null) | ||
420 | |||
421 | for (const token of tokens) { | ||
422 | const body = await checkVideoAccessOptions({ | ||
423 | server, | ||
424 | token, | ||
425 | videoPassword: 'toto', | ||
426 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
427 | mode: tmp | ||
428 | }) | ||
429 | const error = body as unknown as PeerTubeProblemDocument | ||
430 | |||
431 | checkVideoError(error, 'incorrectPassword') | ||
432 | } | ||
433 | }) | ||
434 | |||
435 | it('Should fail if an empty password is entered', async function () { | ||
436 | const tmp = mode === 'get' ? 'getWithPassword' : mode | ||
437 | |||
438 | for (const token of tokens) { | ||
439 | const body = await checkVideoAccessOptions({ | ||
440 | server, | ||
441 | token, | ||
442 | videoPassword: '', | ||
443 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
444 | mode: tmp | ||
445 | }) | ||
446 | const error = body as unknown as PeerTubeProblemDocument | ||
447 | |||
448 | checkVideoError(error, 'incorrectPassword') | ||
449 | } | ||
450 | }) | ||
451 | |||
452 | it('Should fail if an inccorect password containing the correct password is entered', async function () { | ||
453 | const tmp = mode === 'get' ? 'getWithPassword' : mode | ||
454 | |||
455 | for (const token of tokens) { | ||
456 | const body = await checkVideoAccessOptions({ | ||
457 | server, | ||
458 | token, | ||
459 | videoPassword: 'password11', | ||
460 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
461 | mode: tmp | ||
462 | }) | ||
463 | const error = body as unknown as PeerTubeProblemDocument | ||
464 | |||
465 | checkVideoError(error, 'incorrectPassword') | ||
466 | } | ||
467 | }) | ||
468 | |||
469 | it('Should succeed without providing a password for an authorised user', async function () { | ||
470 | const tmp = mode === 'get' ? 'getWithToken' : mode | ||
471 | const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 | ||
472 | |||
473 | const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp }) | ||
474 | |||
475 | if (mode === 'createThread') commentId = body.comment.id | ||
476 | }) | ||
477 | |||
478 | it('Should succeed using correct passwords', async function () { | ||
479 | const tmp = mode === 'get' ? 'getWithPassword' : mode | ||
480 | const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200 | ||
481 | |||
482 | for (const token of tokens) { | ||
483 | await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp }) | ||
484 | await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp }) | ||
485 | } | ||
486 | }) | ||
487 | } | ||
488 | |||
489 | describe('When accessing password protected video', function () { | ||
490 | |||
491 | describe('For getting a password protected video', function () { | ||
492 | validateVideoAccess('get') | ||
493 | }) | ||
494 | |||
495 | describe('For rating a video', function () { | ||
496 | validateVideoAccess('rate') | ||
497 | }) | ||
498 | |||
499 | describe('For creating a thread', function () { | ||
500 | validateVideoAccess('createThread') | ||
501 | }) | ||
502 | |||
503 | describe('For replying to a thread', function () { | ||
504 | validateVideoAccess('replyThread') | ||
505 | }) | ||
506 | |||
507 | describe('For listing threads', function () { | ||
508 | validateVideoAccess('listThreads') | ||
509 | }) | ||
510 | |||
511 | describe('For getting captions', function () { | ||
512 | validateVideoAccess('listCaptions') | ||
513 | }) | ||
514 | |||
515 | describe('For creating video file token', function () { | ||
516 | validateVideoAccess('token') | ||
517 | }) | ||
518 | }) | ||
519 | |||
520 | describe('When listing passwords', function () { | ||
521 | it('Should fail with a bad start pagination', async function () { | ||
522 | await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken) | ||
523 | }) | ||
524 | |||
525 | it('Should fail with a bad count pagination', async function () { | ||
526 | await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken) | ||
527 | }) | ||
528 | |||
529 | it('Should fail with an incorrect sort', async function () { | ||
530 | await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken) | ||
531 | }) | ||
532 | |||
533 | it('Should fail for unauthenticated user', async function () { | ||
534 | await server.videoPasswords.list({ | ||
535 | token: null, | ||
536 | expectedStatus: HttpStatusCode.UNAUTHORIZED_401, | ||
537 | videoId: video.id | ||
538 | }) | ||
539 | }) | ||
540 | |||
541 | it('Should fail for unauthorized user', async function () { | ||
542 | await server.videoPasswords.list({ | ||
543 | token: userAccessToken, | ||
544 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
545 | videoId: video.id | ||
546 | }) | ||
547 | }) | ||
548 | |||
549 | it('Should succeed with the correct parameters', async function () { | ||
550 | await server.videoPasswords.list({ | ||
551 | token: server.accessToken, | ||
552 | expectedStatus: HttpStatusCode.OK_200, | ||
553 | videoId: video.id | ||
554 | }) | ||
555 | }) | ||
556 | }) | ||
557 | |||
558 | describe('When deleting a password', async function () { | ||
559 | const passwords = (await server.videoPasswords.list({ videoId: video.id })).data | ||
560 | |||
561 | it('Should fail with wrong password id', async function () { | ||
562 | await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) | ||
563 | }) | ||
564 | |||
565 | it('Should fail for unauthenticated user', async function () { | ||
566 | await server.videoPasswords.remove({ | ||
567 | id: passwords[0].id, | ||
568 | token: null, | ||
569 | videoId: video.id, | ||
570 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
571 | }) | ||
572 | }) | ||
573 | |||
574 | it('Should fail for unauthorized user', async function () { | ||
575 | await server.videoPasswords.remove({ | ||
576 | id: passwords[0].id, | ||
577 | token: userAccessToken, | ||
578 | videoId: video.id, | ||
579 | expectedStatus: HttpStatusCode.BAD_REQUEST_400 | ||
580 | }) | ||
581 | }) | ||
582 | |||
583 | it('Should fail for non password protected video', async function () { | ||
584 | publicVideo = await server.videos.quickUpload({ name: 'public video' }) | ||
585 | await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
586 | }) | ||
587 | |||
588 | it('Should fail for password not linked to correct video', async function () { | ||
589 | const video2 = await server.videos.quickUpload({ | ||
590 | name: 'password protected video', | ||
591 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
592 | videoPasswords: [ 'password1', 'password2' ] | ||
593 | }) | ||
594 | await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) | ||
595 | }) | ||
596 | |||
597 | it('Should succeed with correct parameter', async function () { | ||
598 | await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 }) | ||
599 | }) | ||
600 | |||
601 | it('Should fail for last password of a video', async function () { | ||
602 | await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
603 | }) | ||
604 | }) | ||
605 | |||
606 | after(async function () { | ||
607 | await cleanupTests([ server ]) | ||
608 | }) | ||
609 | }) | ||
diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts index 7acb9d580..7cb3e84a2 100644 --- a/server/tests/api/check-params/video-token.ts +++ b/server/tests/api/check-params/video-token.ts | |||
@@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ | |||
5 | 5 | ||
6 | describe('Test video tokens', function () { | 6 | describe('Test video tokens', function () { |
7 | let server: PeerTubeServer | 7 | let server: PeerTubeServer |
8 | let videoId: string | 8 | let privateVideoId: string |
9 | let passwordProtectedVideoId: string | ||
9 | let userToken: string | 10 | let userToken: string |
10 | 11 | ||
12 | const videoPassword = 'password' | ||
13 | |||
11 | // --------------------------------------------------------------- | 14 | // --------------------------------------------------------------- |
12 | 15 | ||
13 | before(async function () { | 16 | before(async function () { |
@@ -15,27 +18,50 @@ describe('Test video tokens', function () { | |||
15 | 18 | ||
16 | server = await createSingleServer(1) | 19 | server = await createSingleServer(1) |
17 | await setAccessTokensToServers([ server ]) | 20 | await setAccessTokensToServers([ server ]) |
18 | 21 | { | |
19 | const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) | 22 | const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE }) |
20 | videoId = uuid | 23 | privateVideoId = uuid |
21 | 24 | } | |
25 | { | ||
26 | const { uuid } = await server.videos.quickUpload({ | ||
27 | name: 'password protected video', | ||
28 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
29 | videoPasswords: [ videoPassword ] | ||
30 | }) | ||
31 | passwordProtectedVideoId = uuid | ||
32 | } | ||
22 | userToken = await server.users.generateUserAndToken('user1') | 33 | userToken = await server.users.generateUserAndToken('user1') |
23 | }) | 34 | }) |
24 | 35 | ||
25 | it('Should not generate tokens for unauthenticated user', async function () { | 36 | it('Should not generate tokens on private video for unauthenticated user', async function () { |
26 | await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | 37 | await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) |
27 | }) | 38 | }) |
28 | 39 | ||
29 | it('Should not generate tokens of unknown video', async function () { | 40 | it('Should not generate tokens of unknown video', async function () { |
30 | await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) | 41 | await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) |
31 | }) | 42 | }) |
32 | 43 | ||
44 | it('Should not generate tokens with incorrect password', async function () { | ||
45 | await server.videoToken.create({ | ||
46 | videoId: passwordProtectedVideoId, | ||
47 | token: null, | ||
48 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
49 | videoPassword: 'incorrectPassword' | ||
50 | }) | ||
51 | }) | ||
52 | |||
33 | it('Should not generate tokens of a non owned video', async function () { | 53 | it('Should not generate tokens of a non owned video', async function () { |
34 | await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 54 | await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
35 | }) | 55 | }) |
36 | 56 | ||
37 | it('Should generate token', async function () { | 57 | it('Should generate token', async function () { |
38 | await server.videoToken.create({ videoId }) | 58 | await server.videoToken.create({ videoId: privateVideoId }) |
59 | }) | ||
60 | |||
61 | it('Should generate token on password protected video', async function () { | ||
62 | await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null }) | ||
63 | await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken }) | ||
64 | await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword }) | ||
39 | }) | 65 | }) |
40 | 66 | ||
41 | after(async function () { | 67 | after(async function () { |
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts index af9d681b2..2a7c3381d 100644 --- a/server/tests/api/object-storage/video-static-file-privacy.ts +++ b/server/tests/api/object-storage/video-static-file-privacy.ts | |||
@@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () { | |||
107 | describe('VOD', function () { | 107 | describe('VOD', function () { |
108 | let privateVideoUUID: string | 108 | let privateVideoUUID: string |
109 | let publicVideoUUID: string | 109 | let publicVideoUUID: string |
110 | let passwordProtectedVideoUUID: string | ||
110 | let userPrivateVideoUUID: string | 111 | let userPrivateVideoUUID: string |
111 | 112 | ||
113 | const correctPassword = 'my super password' | ||
114 | const correctPasswordHeader = { 'x-peertube-video-password': correctPassword } | ||
115 | const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' } | ||
116 | |||
112 | // --------------------------------------------------------------------------- | 117 | // --------------------------------------------------------------------------- |
113 | 118 | ||
114 | async function getSampleFileUrls (videoId: string) { | 119 | async function getSampleFileUrls (videoId: string) { |
@@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () { | |||
140 | await checkPrivateVODFiles(privateVideoUUID) | 145 | await checkPrivateVODFiles(privateVideoUUID) |
141 | }) | 146 | }) |
142 | 147 | ||
148 | it('Should upload a password protected video and have appropriate object storage ACL', async function () { | ||
149 | this.timeout(120000) | ||
150 | |||
151 | { | ||
152 | const { uuid } = await server.videos.quickUpload({ | ||
153 | name: 'video', | ||
154 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
155 | videoPasswords: [ correctPassword ] | ||
156 | }) | ||
157 | passwordProtectedVideoUUID = uuid | ||
158 | } | ||
159 | await waitJobs([ server ]) | ||
160 | |||
161 | await checkPrivateVODFiles(passwordProtectedVideoUUID) | ||
162 | }) | ||
163 | |||
143 | it('Should upload a public video and have appropriate object storage ACL', async function () { | 164 | it('Should upload a public video and have appropriate object storage ACL', async function () { |
144 | this.timeout(120000) | 165 | this.timeout(120000) |
145 | 166 | ||
@@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () { | |||
163 | await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | 184 | await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) |
164 | }) | 185 | }) |
165 | 186 | ||
187 | it('Should not get files without appropriate password or appropriate OAuth token', async function () { | ||
188 | this.timeout(60000) | ||
189 | |||
190 | const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) | ||
191 | |||
192 | await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
193 | await makeRawRequest({ | ||
194 | url: webTorrentFile, | ||
195 | token: null, | ||
196 | headers: incorrectPasswordHeader, | ||
197 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
198 | }) | ||
199 | await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | ||
200 | await makeRawRequest({ | ||
201 | url: webTorrentFile, | ||
202 | token: null, | ||
203 | headers: correctPasswordHeader, | ||
204 | expectedStatus: HttpStatusCode.OK_200 | ||
205 | }) | ||
206 | |||
207 | await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
208 | await makeRawRequest({ | ||
209 | url: hlsFile, | ||
210 | token: null, | ||
211 | headers: incorrectPasswordHeader, | ||
212 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
213 | }) | ||
214 | await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | ||
215 | await makeRawRequest({ | ||
216 | url: hlsFile, | ||
217 | token: null, | ||
218 | headers: correctPasswordHeader, | ||
219 | expectedStatus: HttpStatusCode.OK_200 | ||
220 | }) | ||
221 | }) | ||
222 | |||
166 | it('Should not get HLS file of another video', async function () { | 223 | it('Should not get HLS file of another video', async function () { |
167 | this.timeout(60000) | 224 | this.timeout(60000) |
168 | 225 | ||
@@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () { | |||
176 | await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | 233 | await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) |
177 | }) | 234 | }) |
178 | 235 | ||
179 | it('Should correctly check OAuth or video file token', async function () { | 236 | it('Should correctly check OAuth, video file token of private video', async function () { |
180 | this.timeout(60000) | 237 | this.timeout(60000) |
181 | 238 | ||
182 | const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) | 239 | const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) |
@@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () { | |||
191 | 248 | ||
192 | await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 249 | await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
193 | await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) | 250 | await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) |
251 | |||
252 | } | ||
253 | }) | ||
254 | |||
255 | it('Should correctly check OAuth, video file token or video password of password protected video', async function () { | ||
256 | this.timeout(60000) | ||
257 | |||
258 | const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) | ||
259 | const goodVideoFileToken = await server.videoToken.getVideoFileToken({ | ||
260 | videoId: passwordProtectedVideoUUID, | ||
261 | videoPassword: correctPassword | ||
262 | }) | ||
263 | |||
264 | const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID) | ||
265 | |||
266 | for (const url of [ hlsFile, webTorrentFile ]) { | ||
267 | await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
268 | await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
269 | await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | ||
270 | |||
271 | await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | ||
272 | await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) | ||
273 | |||
274 | await makeRawRequest({ | ||
275 | url, | ||
276 | headers: incorrectPasswordHeader, | ||
277 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
278 | }) | ||
279 | await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 }) | ||
194 | } | 280 | } |
195 | }) | 281 | }) |
196 | 282 | ||
@@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () { | |||
232 | let permanentLiveId: string | 318 | let permanentLiveId: string |
233 | let permanentLive: LiveVideo | 319 | let permanentLive: LiveVideo |
234 | 320 | ||
321 | let passwordProtectedLiveId: string | ||
322 | let passwordProtectedLive: LiveVideo | ||
323 | |||
324 | const correctPassword = 'my super password' | ||
325 | |||
235 | let unrelatedFileToken: string | 326 | let unrelatedFileToken: string |
236 | 327 | ||
237 | // --------------------------------------------------------------------------- | 328 | // --------------------------------------------------------------------------- |
238 | 329 | ||
239 | async function checkLiveFiles (live: LiveVideo, liveId: string) { | 330 | async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) { |
240 | const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) | 331 | const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) |
241 | await server.live.waitUntilPublished({ videoId: liveId }) | 332 | await server.live.waitUntilPublished({ videoId: liveId }) |
242 | 333 | ||
243 | const video = await server.videos.getWithToken({ id: liveId }) | 334 | const video = videoPassword |
244 | const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) | 335 | ? await server.videos.getWithPassword({ id: liveId, password: videoPassword }) |
336 | : await server.videos.getWithToken({ id: liveId }) | ||
337 | |||
338 | const fileToken = videoPassword | ||
339 | ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword }) | ||
340 | : await server.videoToken.getVideoFileToken({ videoId: video.uuid }) | ||
245 | 341 | ||
246 | const hls = video.streamingPlaylists[0] | 342 | const hls = video.streamingPlaylists[0] |
247 | 343 | ||
@@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () { | |||
253 | 349 | ||
254 | await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) | 350 | await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) |
255 | await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) | 351 | await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) |
256 | 352 | if (videoPassword) { | |
353 | await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) | ||
354 | } | ||
257 | await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 355 | await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
258 | await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 356 | await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
259 | await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 357 | await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
358 | if (videoPassword) { | ||
359 | await makeRawRequest({ | ||
360 | url, | ||
361 | headers: { 'x-peertube-video-password': 'incorrectPassword' }, | ||
362 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
363 | }) | ||
364 | } | ||
260 | } | 365 | } |
261 | 366 | ||
262 | await stopFfmpeg(ffmpegCommand) | 367 | await stopFfmpeg(ffmpegCommand) |
@@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () { | |||
326 | permanentLiveId = video.uuid | 431 | permanentLiveId = video.uuid |
327 | permanentLive = live | 432 | permanentLive = live |
328 | } | 433 | } |
434 | |||
435 | { | ||
436 | const { video, live } = await server.live.quickCreate({ | ||
437 | saveReplay: false, | ||
438 | permanentLive: false, | ||
439 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
440 | videoPasswords: [ correctPassword ] | ||
441 | }) | ||
442 | passwordProtectedLiveId = video.uuid | ||
443 | passwordProtectedLive = live | ||
444 | } | ||
329 | }) | 445 | }) |
330 | 446 | ||
331 | it('Should create a private normal live and have a private static path', async function () { | 447 | it('Should create a private normal live and have a private static path', async function () { |
@@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () { | |||
340 | await checkLiveFiles(permanentLive, permanentLiveId) | 456 | await checkLiveFiles(permanentLive, permanentLiveId) |
341 | }) | 457 | }) |
342 | 458 | ||
459 | it('Should create a password protected live and have a private static path', async function () { | ||
460 | this.timeout(240000) | ||
461 | |||
462 | await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword) | ||
463 | }) | ||
464 | |||
343 | it('Should reinject video file token in permanent live', async function () { | 465 | it('Should reinject video file token in permanent live', async function () { |
344 | this.timeout(240000) | 466 | this.timeout(240000) |
345 | 467 | ||
diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts new file mode 100644 index 000000000..e01a93a4d --- /dev/null +++ b/server/tests/api/videos/video-passwords.ts | |||
@@ -0,0 +1,97 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { expect } from 'chai' | ||
4 | import { | ||
5 | cleanupTests, | ||
6 | createSingleServer, | ||
7 | VideoPasswordsCommand, | ||
8 | PeerTubeServer, | ||
9 | setAccessTokensToServers, | ||
10 | setDefaultAccountAvatar, | ||
11 | setDefaultChannelAvatar | ||
12 | } from '@shared/server-commands' | ||
13 | import { VideoPrivacy } from '@shared/models' | ||
14 | |||
15 | describe('Test video passwords', function () { | ||
16 | let server: PeerTubeServer | ||
17 | let videoUUID: string | ||
18 | |||
19 | let userAccessTokenServer1: string | ||
20 | |||
21 | let videoPasswords: string[] = [] | ||
22 | let command: VideoPasswordsCommand | ||
23 | |||
24 | before(async function () { | ||
25 | this.timeout(30000) | ||
26 | |||
27 | server = await createSingleServer(1) | ||
28 | |||
29 | await setAccessTokensToServers([ server ]) | ||
30 | |||
31 | for (let i = 0; i < 10; i++) { | ||
32 | videoPasswords.push(`password ${i + 1}`) | ||
33 | } | ||
34 | const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } }) | ||
35 | videoUUID = uuid | ||
36 | |||
37 | await setDefaultChannelAvatar(server) | ||
38 | await setDefaultAccountAvatar(server) | ||
39 | |||
40 | userAccessTokenServer1 = await server.users.generateUserAndToken('user1') | ||
41 | await setDefaultChannelAvatar(server, 'user1_channel') | ||
42 | await setDefaultAccountAvatar(server, userAccessTokenServer1) | ||
43 | |||
44 | command = server.videoPasswords | ||
45 | }) | ||
46 | |||
47 | it('Should list video passwords', async function () { | ||
48 | const body = await command.list({ videoId: videoUUID }) | ||
49 | |||
50 | expect(body.total).to.equal(10) | ||
51 | expect(body.data).to.be.an('array') | ||
52 | expect(body.data).to.have.lengthOf(10) | ||
53 | }) | ||
54 | |||
55 | it('Should filter passwords on this video', async function () { | ||
56 | const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' }) | ||
57 | |||
58 | expect(body.total).to.equal(10) | ||
59 | expect(body.data).to.be.an('array') | ||
60 | expect(body.data).to.have.lengthOf(2) | ||
61 | expect(body.data[0].password).to.equal('password 4') | ||
62 | expect(body.data[1].password).to.equal('password 5') | ||
63 | }) | ||
64 | |||
65 | it('Should update password for this video', async function () { | ||
66 | videoPasswords = [ 'my super new password 1', 'my super new password 2' ] | ||
67 | |||
68 | await command.updateAll({ videoId: videoUUID, passwords: videoPasswords }) | ||
69 | const body = await command.list({ videoId: videoUUID }) | ||
70 | expect(body.total).to.equal(2) | ||
71 | expect(body.data).to.be.an('array') | ||
72 | expect(body.data).to.have.lengthOf(2) | ||
73 | expect(body.data[0].password).to.equal('my super new password 2') | ||
74 | expect(body.data[1].password).to.equal('my super new password 1') | ||
75 | }) | ||
76 | |||
77 | it('Should delete one password', async function () { | ||
78 | { | ||
79 | const body = await command.list({ videoId: videoUUID }) | ||
80 | expect(body.total).to.equal(2) | ||
81 | expect(body.data).to.be.an('array') | ||
82 | expect(body.data).to.have.lengthOf(2) | ||
83 | await command.remove({ id: body.data[0].id, videoId: videoUUID }) | ||
84 | } | ||
85 | { | ||
86 | const body = await command.list({ videoId: videoUUID }) | ||
87 | |||
88 | expect(body.total).to.equal(1) | ||
89 | expect(body.data).to.be.an('array') | ||
90 | expect(body.data).to.have.lengthOf(1) | ||
91 | } | ||
92 | }) | ||
93 | |||
94 | after(async function () { | ||
95 | await cleanupTests([ server ]) | ||
96 | }) | ||
97 | }) | ||
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts index d9c5bdf16..9277b49f4 100644 --- a/server/tests/api/videos/video-playlists.ts +++ b/server/tests/api/videos/video-playlists.ts | |||
@@ -474,7 +474,7 @@ describe('Test video playlists', function () { | |||
474 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) | 474 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) |
475 | }) | 475 | }) |
476 | 476 | ||
477 | it('Should get unlisted plyaylist using uuid or shortUUID', async function () { | 477 | it('Should get unlisted playlist using uuid or shortUUID', async function () { |
478 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) | 478 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) |
479 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) | 479 | await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) |
480 | }) | 480 | }) |
@@ -686,7 +686,7 @@ describe('Test video playlists', function () { | |||
686 | await waitJobs(servers) | 686 | await waitJobs(servers) |
687 | }) | 687 | }) |
688 | 688 | ||
689 | it('Should update the element type if the video is private', async function () { | 689 | it('Should update the element type if the video is private/password protected', async function () { |
690 | this.timeout(20000) | 690 | this.timeout(20000) |
691 | 691 | ||
692 | const name = 'video 89' | 692 | const name = 'video 89' |
@@ -703,6 +703,19 @@ describe('Test video playlists', function () { | |||
703 | } | 703 | } |
704 | 704 | ||
705 | { | 705 | { |
706 | await servers[0].videos.update({ | ||
707 | id: video1, | ||
708 | attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } | ||
709 | }) | ||
710 | await waitJobs(servers) | ||
711 | |||
712 | await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) | ||
713 | await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) | ||
714 | await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) | ||
715 | await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) | ||
716 | } | ||
717 | |||
718 | { | ||
706 | await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) | 719 | await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) |
707 | await waitJobs(servers) | 720 | await waitJobs(servers) |
708 | 721 | ||
diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts index 542848533..ec4c697db 100644 --- a/server/tests/api/videos/video-static-file-privacy.ts +++ b/server/tests/api/videos/video-static-file-privacy.ts | |||
@@ -90,7 +90,7 @@ describe('Test video static file privacy', function () { | |||
90 | } | 90 | } |
91 | } | 91 | } |
92 | 92 | ||
93 | it('Should upload a private/internal video and have a private static path', async function () { | 93 | it('Should upload a private/internal/password protected video and have a private static path', async function () { |
94 | this.timeout(120000) | 94 | this.timeout(120000) |
95 | 95 | ||
96 | for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { | 96 | for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { |
@@ -99,6 +99,15 @@ describe('Test video static file privacy', function () { | |||
99 | 99 | ||
100 | await checkPrivateFiles(uuid) | 100 | await checkPrivateFiles(uuid) |
101 | } | 101 | } |
102 | |||
103 | const { uuid } = await server.videos.quickUpload({ | ||
104 | name: 'video', | ||
105 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
106 | videoPasswords: [ 'my super password' ] | ||
107 | }) | ||
108 | await waitJobs([ server ]) | ||
109 | |||
110 | await checkPrivateFiles(uuid) | ||
102 | }) | 111 | }) |
103 | 112 | ||
104 | it('Should upload a public video and update it as private/internal to have a private static path', async function () { | 113 | it('Should upload a public video and update it as private/internal to have a private static path', async function () { |
@@ -185,8 +194,9 @@ describe('Test video static file privacy', function () { | |||
185 | expectedStatus: HttpStatusCode | 194 | expectedStatus: HttpStatusCode |
186 | token: string | 195 | token: string |
187 | videoFileToken: string | 196 | videoFileToken: string |
197 | videoPassword?: string | ||
188 | }) { | 198 | }) { |
189 | const { id, expectedStatus, token, videoFileToken } = options | 199 | const { id, expectedStatus, token, videoFileToken, videoPassword } = options |
190 | 200 | ||
191 | const video = await server.videos.getWithToken({ id }) | 201 | const video = await server.videos.getWithToken({ id }) |
192 | 202 | ||
@@ -196,6 +206,12 @@ describe('Test video static file privacy', function () { | |||
196 | 206 | ||
197 | await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) | 207 | await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) |
198 | await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) | 208 | await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) |
209 | |||
210 | if (videoPassword) { | ||
211 | const headers = { 'x-peertube-video-password': videoPassword } | ||
212 | await makeRawRequest({ url: file.fileUrl, headers, expectedStatus }) | ||
213 | await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus }) | ||
214 | } | ||
199 | } | 215 | } |
200 | 216 | ||
201 | const hls = video.streamingPlaylists[0] | 217 | const hls = video.streamingPlaylists[0] |
@@ -204,6 +220,12 @@ describe('Test video static file privacy', function () { | |||
204 | 220 | ||
205 | await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) | 221 | await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) |
206 | await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) | 222 | await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) |
223 | |||
224 | if (videoPassword) { | ||
225 | const headers = { 'x-peertube-video-password': videoPassword } | ||
226 | await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus }) | ||
227 | await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus }) | ||
228 | } | ||
207 | } | 229 | } |
208 | 230 | ||
209 | before(async function () { | 231 | before(async function () { |
@@ -216,13 +238,53 @@ describe('Test video static file privacy', function () { | |||
216 | it('Should not be able to access a private video files without OAuth token and file token', async function () { | 238 | it('Should not be able to access a private video files without OAuth token and file token', async function () { |
217 | this.timeout(120000) | 239 | this.timeout(120000) |
218 | 240 | ||
219 | const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) | 241 | const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) |
220 | await waitJobs([ server ]) | 242 | await waitJobs([ server ]) |
221 | 243 | ||
222 | await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) | 244 | await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) |
223 | }) | 245 | }) |
224 | 246 | ||
225 | it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { | 247 | it('Should not be able to access password protected video files without OAuth token, file token and password', async function () { |
248 | this.timeout(120000) | ||
249 | const videoPassword = 'my super password' | ||
250 | |||
251 | const { uuid } = await server.videos.quickUpload({ | ||
252 | name: 'password protected video', | ||
253 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
254 | videoPasswords: [ videoPassword ] | ||
255 | }) | ||
256 | await waitJobs([ server ]) | ||
257 | |||
258 | await checkVideoFiles({ | ||
259 | id: uuid, | ||
260 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
261 | token: null, | ||
262 | videoFileToken: null, | ||
263 | videoPassword: null | ||
264 | }) | ||
265 | }) | ||
266 | |||
267 | it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () { | ||
268 | this.timeout(120000) | ||
269 | const videoPassword = 'my super password' | ||
270 | |||
271 | const { uuid } = await server.videos.quickUpload({ | ||
272 | name: 'password protected video', | ||
273 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
274 | videoPasswords: [ videoPassword ] | ||
275 | }) | ||
276 | await waitJobs([ server ]) | ||
277 | |||
278 | await checkVideoFiles({ | ||
279 | id: uuid, | ||
280 | expectedStatus: HttpStatusCode.FORBIDDEN_403, | ||
281 | token: userToken, | ||
282 | videoFileToken: unrelatedFileToken, | ||
283 | videoPassword: 'incorrectPassword' | ||
284 | }) | ||
285 | }) | ||
286 | |||
287 | it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () { | ||
226 | this.timeout(120000) | 288 | this.timeout(120000) |
227 | 289 | ||
228 | const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) | 290 | const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) |
@@ -247,6 +309,23 @@ describe('Test video static file privacy', function () { | |||
247 | await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) | 309 | await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) |
248 | }) | 310 | }) |
249 | 311 | ||
312 | it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () { | ||
313 | this.timeout(120000) | ||
314 | const videoPassword = 'my super password' | ||
315 | |||
316 | const { uuid } = await server.videos.quickUpload({ | ||
317 | name: 'video', | ||
318 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
319 | videoPasswords: [ videoPassword ] | ||
320 | }) | ||
321 | |||
322 | const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword }) | ||
323 | |||
324 | await waitJobs([ server ]) | ||
325 | |||
326 | await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword }) | ||
327 | }) | ||
328 | |||
250 | it('Should reinject video file token', async function () { | 329 | it('Should reinject video file token', async function () { |
251 | this.timeout(120000) | 330 | this.timeout(120000) |
252 | 331 | ||
@@ -294,13 +373,20 @@ describe('Test video static file privacy', function () { | |||
294 | let permanentLiveId: string | 373 | let permanentLiveId: string |
295 | let permanentLive: LiveVideo | 374 | let permanentLive: LiveVideo |
296 | 375 | ||
376 | let passwordProtectedLiveId: string | ||
377 | let passwordProtectedLive: LiveVideo | ||
378 | |||
379 | const correctPassword = 'my super password' | ||
380 | |||
297 | let unrelatedFileToken: string | 381 | let unrelatedFileToken: string |
298 | 382 | ||
299 | async function checkLiveFiles (live: LiveVideo, liveId: string) { | 383 | async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) { |
384 | const { live, liveId, videoPassword } = options | ||
300 | const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) | 385 | const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) |
301 | await server.live.waitUntilPublished({ videoId: liveId }) | 386 | await server.live.waitUntilPublished({ videoId: liveId }) |
302 | 387 | ||
303 | const video = await server.videos.getWithToken({ id: liveId }) | 388 | const video = await server.videos.getWithToken({ id: liveId }) |
389 | |||
304 | const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) | 390 | const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) |
305 | 391 | ||
306 | const hls = video.streamingPlaylists[0] | 392 | const hls = video.streamingPlaylists[0] |
@@ -314,6 +400,16 @@ describe('Test video static file privacy', function () { | |||
314 | await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 400 | await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
315 | await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 401 | await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
316 | await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) | 402 | await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) |
403 | |||
404 | if (videoPassword) { | ||
405 | await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) | ||
406 | await makeRawRequest({ | ||
407 | url, | ||
408 | headers: { 'x-peertube-video-password': 'incorrectPassword' }, | ||
409 | expectedStatus: HttpStatusCode.FORBIDDEN_403 | ||
410 | }) | ||
411 | } | ||
412 | |||
317 | } | 413 | } |
318 | 414 | ||
319 | await stopFfmpeg(ffmpegCommand) | 415 | await stopFfmpeg(ffmpegCommand) |
@@ -381,18 +477,35 @@ describe('Test video static file privacy', function () { | |||
381 | permanentLiveId = video.uuid | 477 | permanentLiveId = video.uuid |
382 | permanentLive = live | 478 | permanentLive = live |
383 | } | 479 | } |
480 | |||
481 | { | ||
482 | const { video, live } = await server.live.quickCreate({ | ||
483 | saveReplay: false, | ||
484 | permanentLive: false, | ||
485 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
486 | videoPasswords: [ correctPassword ] | ||
487 | }) | ||
488 | passwordProtectedLiveId = video.uuid | ||
489 | passwordProtectedLive = live | ||
490 | } | ||
384 | }) | 491 | }) |
385 | 492 | ||
386 | it('Should create a private normal live and have a private static path', async function () { | 493 | it('Should create a private normal live and have a private static path', async function () { |
387 | this.timeout(240000) | 494 | this.timeout(240000) |
388 | 495 | ||
389 | await checkLiveFiles(normalLive, normalLiveId) | 496 | await checkLiveFiles({ live: normalLive, liveId: normalLiveId }) |
390 | }) | 497 | }) |
391 | 498 | ||
392 | it('Should create a private permanent live and have a private static path', async function () { | 499 | it('Should create a private permanent live and have a private static path', async function () { |
393 | this.timeout(240000) | 500 | this.timeout(240000) |
394 | 501 | ||
395 | await checkLiveFiles(permanentLive, permanentLiveId) | 502 | await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId }) |
503 | }) | ||
504 | |||
505 | it('Should create a password protected live and have a private static path', async function () { | ||
506 | this.timeout(240000) | ||
507 | |||
508 | await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword }) | ||
396 | }) | 509 | }) |
397 | 510 | ||
398 | it('Should reinject video file token on permanent live', async function () { | 511 | it('Should reinject video file token on permanent live', async function () { |
diff --git a/server/tests/client.ts b/server/tests/client.ts index e84251561..68f3a1d14 100644 --- a/server/tests/client.ts +++ b/server/tests/client.ts | |||
@@ -56,6 +56,7 @@ describe('Test a client controllers', function () { | |||
56 | let privateVideoId: string | 56 | let privateVideoId: string |
57 | let internalVideoId: string | 57 | let internalVideoId: string |
58 | let unlistedVideoId: string | 58 | let unlistedVideoId: string |
59 | let passwordProtectedVideoId: string | ||
59 | 60 | ||
60 | let playlistIds: (string | number)[] = [] | 61 | let playlistIds: (string | number)[] = [] |
61 | 62 | ||
@@ -92,7 +93,12 @@ describe('Test a client controllers', function () { | |||
92 | { | 93 | { |
93 | ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); | 94 | ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); |
94 | ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); | 95 | ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); |
95 | ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })) | 96 | ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })); |
97 | ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({ | ||
98 | name: 'password protected', | ||
99 | privacy: VideoPrivacy.PASSWORD_PROTECTED, | ||
100 | videoPasswords: [ 'password' ] | ||
101 | })) | ||
96 | } | 102 | } |
97 | 103 | ||
98 | // Playlist | 104 | // Playlist |
@@ -502,9 +508,9 @@ describe('Test a client controllers', function () { | |||
502 | } | 508 | } |
503 | }) | 509 | }) |
504 | 510 | ||
505 | it('Should not display internal/private video', async function () { | 511 | it('Should not display internal/private/password protected video', async function () { |
506 | for (const basePath of watchVideoBasePaths) { | 512 | for (const basePath of watchVideoBasePaths) { |
507 | for (const id of [ privateVideoId, internalVideoId ]) { | 513 | for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) { |
508 | const res = await makeGetRequest({ | 514 | const res = await makeGetRequest({ |
509 | url: servers[0].url, | 515 | url: servers[0].url, |
510 | path: basePath + id, | 516 | path: basePath + id, |
@@ -514,6 +520,7 @@ describe('Test a client controllers', function () { | |||
514 | 520 | ||
515 | expect(res.text).to.not.contain('internal') | 521 | expect(res.text).to.not.contain('internal') |
516 | expect(res.text).to.not.contain('private') | 522 | expect(res.text).to.not.contain('private') |
523 | expect(res.text).to.not.contain('password protected') | ||
517 | } | 524 | } |
518 | } | 525 | } |
519 | }) | 526 | }) |
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts index 8433c873e..83a85be58 100644 --- a/server/tests/feeds/feeds.ts +++ b/server/tests/feeds/feeds.ts | |||
@@ -99,6 +99,13 @@ describe('Test syndication feeds', () => { | |||
99 | await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) | 99 | await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) |
100 | } | 100 | } |
101 | 101 | ||
102 | { | ||
103 | const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } | ||
104 | const { id } = await servers[0].videos.upload({ attributes }) | ||
105 | |||
106 | await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' }) | ||
107 | } | ||
108 | |||
102 | await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) | 109 | await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) |
103 | 110 | ||
104 | await waitJobs([ ...servers, serverHLSOnly ]) | 111 | await waitJobs([ ...servers, serverHLSOnly ]) |
@@ -445,7 +452,7 @@ describe('Test syndication feeds', () => { | |||
445 | 452 | ||
446 | describe('Video comments feed', function () { | 453 | describe('Video comments feed', function () { |
447 | 454 | ||
448 | it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { | 455 | it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () { |
449 | for (const server of servers) { | 456 | for (const server of servers) { |
450 | const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) | 457 | const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) |
451 | 458 | ||
diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 510b9f94e..9c1be9bd1 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts | |||
@@ -18,6 +18,7 @@ import { | |||
18 | MVideoId, | 18 | MVideoId, |
19 | MVideoImmutable, | 19 | MVideoImmutable, |
20 | MVideoLiveFormattable, | 20 | MVideoLiveFormattable, |
21 | MVideoPassword, | ||
21 | MVideoPlaylistFull, | 22 | MVideoPlaylistFull, |
22 | MVideoPlaylistFullSummary | 23 | MVideoPlaylistFullSummary |
23 | } from '@server/types/models' | 24 | } from '@server/types/models' |
@@ -165,6 +166,8 @@ declare module 'express' { | |||
165 | videoCommentFull?: MCommentOwnerVideoReply | 166 | videoCommentFull?: MCommentOwnerVideoReply |
166 | videoCommentThread?: MComment | 167 | videoCommentThread?: MComment |
167 | 168 | ||
169 | videoPassword?: MVideoPassword | ||
170 | |||
168 | follow?: MActorFollowActorsDefault | 171 | follow?: MActorFollowActorsDefault |
169 | subscription?: MActorFollowActorsDefaultSubscription | 172 | subscription?: MActorFollowActorsDefaultSubscription |
170 | 173 | ||
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts index 6e45fcc79..0ac032290 100644 --- a/server/types/models/video/index.ts +++ b/server/types/models/video/index.ts | |||
@@ -16,6 +16,7 @@ export * from './video-import' | |||
16 | export * from './video-live-replay-setting' | 16 | export * from './video-live-replay-setting' |
17 | export * from './video-live-session' | 17 | export * from './video-live-session' |
18 | export * from './video-live' | 18 | export * from './video-live' |
19 | export * from './video-password' | ||
19 | export * from './video-playlist' | 20 | export * from './video-playlist' |
20 | export * from './video-playlist-element' | 21 | export * from './video-playlist-element' |
21 | export * from './video-rate' | 22 | 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 @@ | |||
1 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
2 | |||
3 | export type MVideoPassword = Omit<VideoPasswordModel, 'Video'> | ||
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts index 58ae7baad..8021e56bb 100644 --- a/server/types/models/video/video.ts +++ b/server/types/models/video/video.ts | |||
@@ -32,7 +32,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M> | |||
32 | export type MVideo = | 32 | export type MVideo = |
33 | Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | | 33 | Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | |
34 | 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | | 34 | 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | |
35 | 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers'> | 35 | 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'> |
36 | 36 | ||
37 | // ############################################################################ | 37 | // ############################################################################ |
38 | 38 | ||
@@ -46,7 +46,7 @@ export type MVideoFeed = Pick<MVideo, 'name' | 'uuid'> | |||
46 | 46 | ||
47 | // ############################################################################ | 47 | // ############################################################################ |
48 | 48 | ||
49 | // Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists | 49 | // Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords |
50 | 50 | ||
51 | // "With" to not confuse with the VideoFile model | 51 | // "With" to not confuse with the VideoFile model |
52 | export type MVideoWithFile = | 52 | 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' | |||
3 | import { VideoDetails } from '../../models/videos/video.model' | 3 | import { VideoDetails } from '../../models/videos/video.model' |
4 | 4 | ||
5 | function getAllPrivacies () { | 5 | function getAllPrivacies () { |
6 | return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ] | 6 | return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ] |
7 | } | 7 | } |
8 | 8 | ||
9 | function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { | 9 | function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { |
diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts index 2b093380c..77d1e1d3f 100644 --- a/shared/models/server/server-error-code.enum.ts +++ b/shared/models/server/server-error-code.enum.ts | |||
@@ -49,7 +49,10 @@ export const enum ServerErrorCode { | |||
49 | 49 | ||
50 | RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', | 50 | RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', |
51 | RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state', | 51 | RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state', |
52 | UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token' | 52 | UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token', |
53 | |||
54 | VIDEO_REQUIRES_PASSWORD = 'video_requires_password', | ||
55 | INCORRECT_VIDEO_PASSWORD = 'incorrect_video_password' | ||
53 | } | 56 | } |
54 | 57 | ||
55 | /** | 58 | /** |
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 4c1790228..80be1854b 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts | |||
@@ -39,3 +39,4 @@ export * from './video-update.model' | |||
39 | export * from './video-view.model' | 39 | export * from './video-view.model' |
40 | export * from './video.model' | 40 | export * from './video.model' |
41 | export * from './video-create-result.model' | 41 | export * from './video-create-result.model' |
42 | 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 { | |||
18 | privacy: VideoPrivacy | 18 | privacy: VideoPrivacy |
19 | scheduleUpdate?: VideoScheduleUpdate | 19 | scheduleUpdate?: VideoScheduleUpdate |
20 | originallyPublishedAt?: Date | string | 20 | originallyPublishedAt?: Date | string |
21 | videoPasswords?: string[] | ||
21 | 22 | ||
22 | thumbnailfile?: Blob | string | 23 | thumbnailfile?: Blob | string |
23 | previewfile?: Blob | string | 24 | previewfile?: Blob | string |
diff --git a/shared/models/videos/video-password.model.ts b/shared/models/videos/video-password.model.ts new file mode 100644 index 000000000..c0280b9b9 --- /dev/null +++ b/shared/models/videos/video-password.model.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export interface VideoPassword { | ||
2 | id: number | ||
3 | password: string | ||
4 | videoId: number | ||
5 | createdAt: Date | string | ||
6 | updatedAt: Date | string | ||
7 | } | ||
diff --git a/shared/models/videos/video-privacy.enum.ts b/shared/models/videos/video-privacy.enum.ts index 39fd0529f..12e1d196f 100644 --- a/shared/models/videos/video-privacy.enum.ts +++ b/shared/models/videos/video-privacy.enum.ts | |||
@@ -2,5 +2,6 @@ export const enum VideoPrivacy { | |||
2 | PUBLIC = 1, | 2 | PUBLIC = 1, |
3 | UNLISTED = 2, | 3 | UNLISTED = 2, |
4 | PRIVATE = 3, | 4 | PRIVATE = 3, |
5 | INTERNAL = 4 | 5 | INTERNAL = 4, |
6 | PASSWORD_PROTECTED = 5 | ||
6 | } | 7 | } |
diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts index 86653b959..43537b5af 100644 --- a/shared/models/videos/video-update.model.ts +++ b/shared/models/videos/video-update.model.ts | |||
@@ -19,6 +19,7 @@ export interface VideoUpdate { | |||
19 | previewfile?: Blob | 19 | previewfile?: Blob |
20 | scheduleUpdate?: VideoScheduleUpdate | 20 | scheduleUpdate?: VideoScheduleUpdate |
21 | originallyPublishedAt?: Date | string | 21 | originallyPublishedAt?: Date | string |
22 | videoPasswords?: string[] | ||
22 | 23 | ||
23 | pluginData?: any | 24 | pluginData?: any |
24 | } | 25 | } |
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts index e3f1817f1..8227017eb 100644 --- a/shared/server-commands/requests/requests.ts +++ b/shared/server-commands/requests/requests.ts | |||
@@ -29,6 +29,7 @@ function makeRawRequest (options: { | |||
29 | range?: string | 29 | range?: string |
30 | query?: { [ id: string ]: string } | 30 | query?: { [ id: string ]: string } |
31 | method?: 'GET' | 'POST' | 31 | method?: 'GET' | 'POST' |
32 | headers?: { [ name: string ]: string } | ||
32 | }) { | 33 | }) { |
33 | const { host, protocol, pathname } = new URL(options.url) | 34 | const { host, protocol, pathname } = new URL(options.url) |
34 | 35 | ||
@@ -37,7 +38,7 @@ function makeRawRequest (options: { | |||
37 | path: pathname, | 38 | path: pathname, |
38 | contentType: undefined, | 39 | contentType: undefined, |
39 | 40 | ||
40 | ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ]) | 41 | ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ]) |
41 | } | 42 | } |
42 | 43 | ||
43 | if (options.method === 'POST') { | 44 | if (options.method === 'POST') { |
@@ -132,6 +133,7 @@ function makePutBodyRequest (options: { | |||
132 | token?: string | 133 | token?: string |
133 | fields: { [ fieldName: string ]: any } | 134 | fields: { [ fieldName: string ]: any } |
134 | expectedStatus?: HttpStatusCode | 135 | expectedStatus?: HttpStatusCode |
136 | headers?: { [name: string]: string } | ||
135 | }) { | 137 | }) { |
136 | const req = request(options.url).put(options.path) | 138 | const req = request(options.url).put(options.path) |
137 | .send(options.fields) | 139 | .send(options.fields) |
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index 70f7a3ee2..0911e22b0 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts | |||
@@ -32,6 +32,7 @@ import { | |||
32 | HistoryCommand, | 32 | HistoryCommand, |
33 | ImportsCommand, | 33 | ImportsCommand, |
34 | LiveCommand, | 34 | LiveCommand, |
35 | VideoPasswordsCommand, | ||
35 | PlaylistsCommand, | 36 | PlaylistsCommand, |
36 | ServicesCommand, | 37 | ServicesCommand, |
37 | StreamingPlaylistsCommand, | 38 | StreamingPlaylistsCommand, |
@@ -146,6 +147,7 @@ export class PeerTubeServer { | |||
146 | twoFactor?: TwoFactorCommand | 147 | twoFactor?: TwoFactorCommand |
147 | videoToken?: VideoTokenCommand | 148 | videoToken?: VideoTokenCommand |
148 | registrations?: RegistrationsCommand | 149 | registrations?: RegistrationsCommand |
150 | videoPasswords?: VideoPasswordsCommand | ||
149 | 151 | ||
150 | runners?: RunnersCommand | 152 | runners?: RunnersCommand |
151 | runnerRegistrationTokens?: RunnerRegistrationTokensCommand | 153 | runnerRegistrationTokens?: RunnerRegistrationTokensCommand |
@@ -437,5 +439,6 @@ export class PeerTubeServer { | |||
437 | this.runners = new RunnersCommand(this) | 439 | this.runners = new RunnersCommand(this) |
438 | this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) | 440 | this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) |
439 | this.runnerJobs = new RunnerJobsCommand(this) | 441 | this.runnerJobs = new RunnerJobsCommand(this) |
442 | this.videoPasswords = new VideoPasswordsCommand(this) | ||
440 | } | 443 | } |
441 | } | 444 | } |
diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts index ca4ffada9..463acc26b 100644 --- a/shared/server-commands/shared/abstract-command.ts +++ b/shared/server-commands/shared/abstract-command.ts | |||
@@ -101,25 +101,29 @@ abstract class AbstractCommand { | |||
101 | 101 | ||
102 | protected putBodyRequest (options: InternalCommonCommandOptions & { | 102 | protected putBodyRequest (options: InternalCommonCommandOptions & { |
103 | fields?: { [ fieldName: string ]: any } | 103 | fields?: { [ fieldName: string ]: any } |
104 | headers?: { [name: string]: string } | ||
104 | }) { | 105 | }) { |
105 | const { fields } = options | 106 | const { fields, headers } = options |
106 | 107 | ||
107 | return makePutBodyRequest({ | 108 | return makePutBodyRequest({ |
108 | ...this.buildCommonRequestOptions(options), | 109 | ...this.buildCommonRequestOptions(options), |
109 | 110 | ||
110 | fields | 111 | fields, |
112 | headers | ||
111 | }) | 113 | }) |
112 | } | 114 | } |
113 | 115 | ||
114 | protected postBodyRequest (options: InternalCommonCommandOptions & { | 116 | protected postBodyRequest (options: InternalCommonCommandOptions & { |
115 | fields?: { [ fieldName: string ]: any } | 117 | fields?: { [ fieldName: string ]: any } |
118 | headers?: { [name: string]: string } | ||
116 | }) { | 119 | }) { |
117 | const { fields } = options | 120 | const { fields, headers } = options |
118 | 121 | ||
119 | return makePostBodyRequest({ | 122 | return makePostBodyRequest({ |
120 | ...this.buildCommonRequestOptions(options), | 123 | ...this.buildCommonRequestOptions(options), |
121 | 124 | ||
122 | fields | 125 | fields, |
126 | headers | ||
123 | }) | 127 | }) |
124 | } | 128 | } |
125 | 129 | ||
@@ -206,6 +210,12 @@ abstract class AbstractCommand { | |||
206 | 210 | ||
207 | return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus | 211 | return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus |
208 | } | 212 | } |
213 | |||
214 | protected buildVideoPasswordHeader (videoPassword: string) { | ||
215 | return videoPassword !== undefined && videoPassword !== null | ||
216 | ? { 'x-peertube-video-password': videoPassword } | ||
217 | : undefined | ||
218 | } | ||
209 | } | 219 | } |
210 | 220 | ||
211 | export { | 221 | 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 { | |||
34 | 34 | ||
35 | list (options: OverrideCommandOptions & { | 35 | list (options: OverrideCommandOptions & { |
36 | videoId: string | number | 36 | videoId: string | number |
37 | videoPassword?: string | ||
37 | }) { | 38 | }) { |
38 | const { videoId } = options | 39 | const { videoId, videoPassword } = options |
39 | const path = '/api/v1/videos/' + videoId + '/captions' | 40 | const path = '/api/v1/videos/' + videoId + '/captions' |
40 | 41 | ||
41 | return this.getRequestBody<ResultList<VideoCaption>>({ | 42 | return this.getRequestBody<ResultList<VideoCaption>>({ |
42 | ...options, | 43 | ...options, |
43 | 44 | ||
44 | path, | 45 | path, |
46 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
45 | implicitToken: false, | 47 | implicitToken: false, |
46 | defaultExpectedStatus: HttpStatusCode.OK_200 | 48 | defaultExpectedStatus: HttpStatusCode.OK_200 |
47 | }) | 49 | }) |
diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts index 154ec0c24..0dab1b66a 100644 --- a/shared/server-commands/videos/comments-command.ts +++ b/shared/server-commands/videos/comments-command.ts | |||
@@ -36,11 +36,12 @@ export class CommentsCommand extends AbstractCommand { | |||
36 | 36 | ||
37 | listThreads (options: OverrideCommandOptions & { | 37 | listThreads (options: OverrideCommandOptions & { |
38 | videoId: number | string | 38 | videoId: number | string |
39 | videoPassword?: string | ||
39 | start?: number | 40 | start?: number |
40 | count?: number | 41 | count?: number |
41 | sort?: string | 42 | sort?: string |
42 | }) { | 43 | }) { |
43 | const { start, count, sort, videoId } = options | 44 | const { start, count, sort, videoId, videoPassword } = options |
44 | const path = '/api/v1/videos/' + videoId + '/comment-threads' | 45 | const path = '/api/v1/videos/' + videoId + '/comment-threads' |
45 | 46 | ||
46 | return this.getRequestBody<VideoCommentThreads>({ | 47 | return this.getRequestBody<VideoCommentThreads>({ |
@@ -48,6 +49,7 @@ export class CommentsCommand extends AbstractCommand { | |||
48 | 49 | ||
49 | path, | 50 | path, |
50 | query: { start, count, sort }, | 51 | query: { start, count, sort }, |
52 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
51 | implicitToken: false, | 53 | implicitToken: false, |
52 | defaultExpectedStatus: HttpStatusCode.OK_200 | 54 | defaultExpectedStatus: HttpStatusCode.OK_200 |
53 | }) | 55 | }) |
@@ -72,8 +74,9 @@ export class CommentsCommand extends AbstractCommand { | |||
72 | async createThread (options: OverrideCommandOptions & { | 74 | async createThread (options: OverrideCommandOptions & { |
73 | videoId: number | string | 75 | videoId: number | string |
74 | text: string | 76 | text: string |
77 | videoPassword?: string | ||
75 | }) { | 78 | }) { |
76 | const { videoId, text } = options | 79 | const { videoId, text, videoPassword } = options |
77 | const path = '/api/v1/videos/' + videoId + '/comment-threads' | 80 | const path = '/api/v1/videos/' + videoId + '/comment-threads' |
78 | 81 | ||
79 | const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ | 82 | const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ |
@@ -81,6 +84,7 @@ export class CommentsCommand extends AbstractCommand { | |||
81 | 84 | ||
82 | path, | 85 | path, |
83 | fields: { text }, | 86 | fields: { text }, |
87 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
84 | implicitToken: true, | 88 | implicitToken: true, |
85 | defaultExpectedStatus: HttpStatusCode.OK_200 | 89 | defaultExpectedStatus: HttpStatusCode.OK_200 |
86 | })) | 90 | })) |
@@ -95,8 +99,9 @@ export class CommentsCommand extends AbstractCommand { | |||
95 | videoId: number | string | 99 | videoId: number | string |
96 | toCommentId: number | 100 | toCommentId: number |
97 | text: string | 101 | text: string |
102 | videoPassword?: string | ||
98 | }) { | 103 | }) { |
99 | const { videoId, toCommentId, text } = options | 104 | const { videoId, toCommentId, text, videoPassword } = options |
100 | const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId | 105 | const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId |
101 | 106 | ||
102 | const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ | 107 | const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ |
@@ -104,6 +109,7 @@ export class CommentsCommand extends AbstractCommand { | |||
104 | 109 | ||
105 | path, | 110 | path, |
106 | fields: { text }, | 111 | fields: { text }, |
112 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
107 | implicitToken: true, | 113 | implicitToken: true, |
108 | defaultExpectedStatus: HttpStatusCode.OK_200 | 114 | defaultExpectedStatus: HttpStatusCode.OK_200 |
109 | })) | 115 | })) |
diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index c17f6ef20..da36b5b6b 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts | |||
@@ -17,3 +17,4 @@ export * from './video-studio-command' | |||
17 | export * from './video-token-command' | 17 | export * from './video-token-command' |
18 | export * from './views-command' | 18 | export * from './views-command' |
19 | export * from './videos-command' | 19 | export * from './videos-command' |
20 | 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 { | |||
120 | saveReplay: boolean | 120 | saveReplay: boolean |
121 | permanentLive: boolean | 121 | permanentLive: boolean |
122 | privacy?: VideoPrivacy | 122 | privacy?: VideoPrivacy |
123 | videoPasswords?: string[] | ||
123 | }) { | 124 | }) { |
124 | const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC } = options | 125 | const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options |
126 | |||
127 | const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED | ||
128 | ? { privacy: VideoPrivacy.PRIVATE } | ||
129 | : { privacy } | ||
125 | 130 | ||
126 | const { uuid } = await this.create({ | 131 | const { uuid } = await this.create({ |
127 | ...options, | 132 | ...options, |
@@ -130,9 +135,10 @@ export class LiveCommand extends AbstractCommand { | |||
130 | name: 'live', | 135 | name: 'live', |
131 | permanentLive, | 136 | permanentLive, |
132 | saveReplay, | 137 | saveReplay, |
133 | replaySettings: { privacy }, | 138 | replaySettings, |
134 | channelId: this.server.store.channel.id, | 139 | channelId: this.server.store.channel.id, |
135 | privacy | 140 | privacy, |
141 | videoPasswords | ||
136 | } | 142 | } |
137 | }) | 143 | }) |
138 | 144 | ||
diff --git a/shared/server-commands/videos/video-passwords-command.ts b/shared/server-commands/videos/video-passwords-command.ts new file mode 100644 index 000000000..bf10335b4 --- /dev/null +++ b/shared/server-commands/videos/video-passwords-command.ts | |||
@@ -0,0 +1,55 @@ | |||
1 | import { HttpStatusCode, ResultList, VideoPassword } from '@shared/models' | ||
2 | import { AbstractCommand, OverrideCommandOptions } from '../shared' | ||
3 | export class VideoPasswordsCommand extends AbstractCommand { | ||
4 | |||
5 | list (options: OverrideCommandOptions & { | ||
6 | videoId: number | string | ||
7 | start?: number | ||
8 | count?: number | ||
9 | sort?: string | ||
10 | }) { | ||
11 | const { start, count, sort, videoId } = options | ||
12 | const path = '/api/v1/videos/' + videoId + '/passwords' | ||
13 | |||
14 | return this.getRequestBody<ResultList<VideoPassword>>({ | ||
15 | ...options, | ||
16 | |||
17 | path, | ||
18 | query: { start, count, sort }, | ||
19 | implicitToken: true, | ||
20 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
21 | }) | ||
22 | } | ||
23 | |||
24 | updateAll (options: OverrideCommandOptions & { | ||
25 | videoId: number | string | ||
26 | passwords: string[] | ||
27 | }) { | ||
28 | const { videoId, passwords } = options | ||
29 | const path = `/api/v1/videos/${videoId}/passwords` | ||
30 | |||
31 | return this.putBodyRequest({ | ||
32 | ...options, | ||
33 | path, | ||
34 | fields: { passwords }, | ||
35 | implicitToken: true, | ||
36 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
37 | }) | ||
38 | } | ||
39 | |||
40 | remove (options: OverrideCommandOptions & { | ||
41 | id: number | ||
42 | videoId: number | string | ||
43 | }) { | ||
44 | const { id, videoId } = options | ||
45 | const path = `/api/v1/videos/${videoId}/passwords/${id}` | ||
46 | |||
47 | return this.deleteRequest({ | ||
48 | ...options, | ||
49 | |||
50 | path, | ||
51 | implicitToken: true, | ||
52 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
53 | }) | ||
54 | } | ||
55 | } | ||
diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts index 0531bee65..c4ed29a8c 100644 --- a/shared/server-commands/videos/video-token-command.ts +++ b/shared/server-commands/videos/video-token-command.ts | |||
@@ -8,12 +8,14 @@ export class VideoTokenCommand extends AbstractCommand { | |||
8 | 8 | ||
9 | create (options: OverrideCommandOptions & { | 9 | create (options: OverrideCommandOptions & { |
10 | videoId: number | string | 10 | videoId: number | string |
11 | videoPassword?: string | ||
11 | }) { | 12 | }) { |
12 | const { videoId } = options | 13 | const { videoId, videoPassword } = options |
13 | const path = '/api/v1/videos/' + videoId + '/token' | 14 | const path = '/api/v1/videos/' + videoId + '/token' |
14 | 15 | ||
15 | return unwrapBody<VideoToken>(this.postBodyRequest({ | 16 | return unwrapBody<VideoToken>(this.postBodyRequest({ |
16 | ...options, | 17 | ...options, |
18 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
17 | 19 | ||
18 | path, | 20 | path, |
19 | implicitToken: true, | 21 | implicitToken: true, |
@@ -23,6 +25,7 @@ export class VideoTokenCommand extends AbstractCommand { | |||
23 | 25 | ||
24 | async getVideoFileToken (options: OverrideCommandOptions & { | 26 | async getVideoFileToken (options: OverrideCommandOptions & { |
25 | videoId: number | string | 27 | videoId: number | string |
28 | videoPassword?: string | ||
26 | }) { | 29 | }) { |
27 | const { files } = await this.create(options) | 30 | const { files } = await this.create(options) |
28 | 31 | ||
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts index b5df9c325..93ca623e1 100644 --- a/shared/server-commands/videos/videos-command.ts +++ b/shared/server-commands/videos/videos-command.ts | |||
@@ -111,8 +111,9 @@ export class VideosCommand extends AbstractCommand { | |||
111 | rate (options: OverrideCommandOptions & { | 111 | rate (options: OverrideCommandOptions & { |
112 | id: number | string | 112 | id: number | string |
113 | rating: UserVideoRateType | 113 | rating: UserVideoRateType |
114 | videoPassword?: string | ||
114 | }) { | 115 | }) { |
115 | const { id, rating } = options | 116 | const { id, rating, videoPassword } = options |
116 | const path = '/api/v1/videos/' + id + '/rate' | 117 | const path = '/api/v1/videos/' + id + '/rate' |
117 | 118 | ||
118 | return this.putBodyRequest({ | 119 | return this.putBodyRequest({ |
@@ -120,6 +121,7 @@ export class VideosCommand extends AbstractCommand { | |||
120 | 121 | ||
121 | path, | 122 | path, |
122 | fields: { rating }, | 123 | fields: { rating }, |
124 | headers: this.buildVideoPasswordHeader(videoPassword), | ||
123 | implicitToken: true, | 125 | implicitToken: true, |
124 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | 126 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 |
125 | }) | 127 | }) |
@@ -151,6 +153,23 @@ export class VideosCommand extends AbstractCommand { | |||
151 | }) | 153 | }) |
152 | } | 154 | } |
153 | 155 | ||
156 | getWithPassword (options: OverrideCommandOptions & { | ||
157 | id: number | string | ||
158 | password?: string | ||
159 | }) { | ||
160 | const path = '/api/v1/videos/' + options.id | ||
161 | |||
162 | return this.getRequestBody<VideoDetails>({ | ||
163 | ...options, | ||
164 | headers:{ | ||
165 | 'x-peertube-video-password': options.password | ||
166 | }, | ||
167 | path, | ||
168 | implicitToken: false, | ||
169 | defaultExpectedStatus: HttpStatusCode.OK_200 | ||
170 | }) | ||
171 | } | ||
172 | |||
154 | getSource (options: OverrideCommandOptions & { | 173 | getSource (options: OverrideCommandOptions & { |
155 | id: number | string | 174 | id: number | string |
156 | }) { | 175 | }) { |
@@ -608,11 +627,13 @@ export class VideosCommand extends AbstractCommand { | |||
608 | nsfw?: boolean | 627 | nsfw?: boolean |
609 | privacy?: VideoPrivacy | 628 | privacy?: VideoPrivacy |
610 | fixture?: string | 629 | fixture?: string |
630 | videoPasswords?: string[] | ||
611 | }) { | 631 | }) { |
612 | const attributes: VideoEdit = { name: options.name } | 632 | const attributes: VideoEdit = { name: options.name } |
613 | if (options.nsfw) attributes.nsfw = options.nsfw | 633 | if (options.nsfw) attributes.nsfw = options.nsfw |
614 | if (options.privacy) attributes.privacy = options.privacy | 634 | if (options.privacy) attributes.privacy = options.privacy |
615 | if (options.fixture) attributes.fixture = options.fixture | 635 | if (options.fixture) attributes.fixture = options.fixture |
636 | if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords | ||
616 | 637 | ||
617 | return this.upload({ ...options, attributes }) | 638 | return this.upload({ ...options, attributes }) |
618 | } | 639 | } |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index cd50e86a6..ff94f802b 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -300,6 +300,8 @@ tags: | |||
300 | - name: Runner Registration Token | 300 | - name: Runner Registration Token |
301 | description: | | 301 | description: | |
302 | Manage runner registration token | 302 | Manage runner registration token |
303 | - name: Video Passwords | ||
304 | description: Operation on video passwords | ||
303 | 305 | ||
304 | x-tagGroups: | 306 | x-tagGroups: |
305 | - name: Static endpoints | 307 | - name: Static endpoints |
@@ -337,6 +339,7 @@ x-tagGroups: | |||
337 | - Video Transcoding | 339 | - Video Transcoding |
338 | - Live Videos | 340 | - Live Videos |
339 | - Channels Sync | 341 | - Channels Sync |
342 | - Video Passwords | ||
340 | - name: Search | 343 | - name: Search |
341 | tags: | 344 | tags: |
342 | - Search | 345 | - Search |
@@ -2359,6 +2362,7 @@ paths: | |||
2359 | - OAuth2: [] | 2362 | - OAuth2: [] |
2360 | parameters: | 2363 | parameters: |
2361 | - $ref: '#/components/parameters/idOrUUID' | 2364 | - $ref: '#/components/parameters/idOrUUID' |
2365 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
2362 | responses: | 2366 | responses: |
2363 | '200': | 2367 | '200': |
2364 | description: successful operation | 2368 | description: successful operation |
@@ -2578,6 +2582,8 @@ paths: | |||
2578 | format: date-time | 2582 | format: date-time |
2579 | scheduleUpdate: | 2583 | scheduleUpdate: |
2580 | $ref: '#/components/schemas/VideoScheduledUpdate' | 2584 | $ref: '#/components/schemas/VideoScheduledUpdate' |
2585 | videoPasswords: | ||
2586 | $ref: '#/components/schemas/AddVideoPasswords' | ||
2581 | encoding: | 2587 | encoding: |
2582 | thumbnailfile: | 2588 | thumbnailfile: |
2583 | contentType: image/jpeg | 2589 | contentType: image/jpeg |
@@ -2590,6 +2596,7 @@ paths: | |||
2590 | - Video | 2596 | - Video |
2591 | parameters: | 2597 | parameters: |
2592 | - $ref: '#/components/parameters/idOrUUID' | 2598 | - $ref: '#/components/parameters/idOrUUID' |
2599 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
2593 | responses: | 2600 | responses: |
2594 | '200': | 2601 | '200': |
2595 | description: successful operation | 2602 | description: successful operation |
@@ -2597,6 +2604,8 @@ paths: | |||
2597 | application/json: | 2604 | application/json: |
2598 | schema: | 2605 | schema: |
2599 | $ref: '#/components/schemas/VideoDetails' | 2606 | $ref: '#/components/schemas/VideoDetails' |
2607 | '403': | ||
2608 | description: provide a correct password to access this password protected video | ||
2600 | delete: | 2609 | delete: |
2601 | summary: Delete a video | 2610 | summary: Delete a video |
2602 | operationId: delVideo | 2611 | operationId: delVideo |
@@ -2618,6 +2627,7 @@ paths: | |||
2618 | - Video | 2627 | - Video |
2619 | parameters: | 2628 | parameters: |
2620 | - $ref: '#/components/parameters/idOrUUID' | 2629 | - $ref: '#/components/parameters/idOrUUID' |
2630 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
2621 | responses: | 2631 | responses: |
2622 | '200': | 2632 | '200': |
2623 | description: successful operation | 2633 | description: successful operation |
@@ -3267,6 +3277,7 @@ paths: | |||
3267 | - Live Videos | 3277 | - Live Videos |
3268 | parameters: | 3278 | parameters: |
3269 | - $ref: '#/components/parameters/idOrUUID' | 3279 | - $ref: '#/components/parameters/idOrUUID' |
3280 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
3270 | responses: | 3281 | responses: |
3271 | '200': | 3282 | '200': |
3272 | description: successful operation | 3283 | description: successful operation |
@@ -3665,6 +3676,7 @@ paths: | |||
3665 | - Video Captions | 3676 | - Video Captions |
3666 | parameters: | 3677 | parameters: |
3667 | - $ref: '#/components/parameters/idOrUUID' | 3678 | - $ref: '#/components/parameters/idOrUUID' |
3679 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
3668 | responses: | 3680 | responses: |
3669 | '200': | 3681 | '200': |
3670 | description: successful operation | 3682 | description: successful operation |
@@ -3728,6 +3740,70 @@ paths: | |||
3728 | '404': | 3740 | '404': |
3729 | description: video or language or caption for that language not found | 3741 | description: video or language or caption for that language not found |
3730 | 3742 | ||
3743 | /api/v1/videos/{id}/passwords: | ||
3744 | get: | ||
3745 | summary: List video passwords | ||
3746 | security: | ||
3747 | - OAuth2: | ||
3748 | - user | ||
3749 | tags: | ||
3750 | - Video Passwords | ||
3751 | parameters: | ||
3752 | - $ref: '#/components/parameters/idOrUUID' | ||
3753 | - $ref: '#/components/parameters/start' | ||
3754 | - $ref: '#/components/parameters/count' | ||
3755 | - $ref: '#/components/parameters/sort' | ||
3756 | responses: | ||
3757 | '204': | ||
3758 | description: successful operation | ||
3759 | content: | ||
3760 | application/json: | ||
3761 | schema: | ||
3762 | $ref: '#/components/schemas/VideoPasswordList' | ||
3763 | '400': | ||
3764 | description: video is not password protected | ||
3765 | put: | ||
3766 | summary: Update video passwords | ||
3767 | security: | ||
3768 | - OAuth2: | ||
3769 | - user | ||
3770 | tags: | ||
3771 | - Video Passwords | ||
3772 | parameters: | ||
3773 | - $ref: '#/components/parameters/idOrUUID' | ||
3774 | requestBody: | ||
3775 | content: | ||
3776 | application/json: | ||
3777 | schema: | ||
3778 | type: object | ||
3779 | properties: | ||
3780 | passwords: | ||
3781 | $ref: '#/components/schemas/AddVideoPasswords' | ||
3782 | responses: | ||
3783 | '204': | ||
3784 | description: successful operation | ||
3785 | '400': | ||
3786 | description: video is not password protected | ||
3787 | |||
3788 | /api/v1/videos/{id}/passwords/{videoPasswordId}: | ||
3789 | delete: | ||
3790 | summary: Delete a video password | ||
3791 | security: | ||
3792 | - OAuth2: | ||
3793 | - user | ||
3794 | tags: | ||
3795 | - Video Passwords | ||
3796 | parameters: | ||
3797 | - $ref: '#/components/parameters/idOrUUID' | ||
3798 | - $ref: '#/components/parameters/videoPasswordId' | ||
3799 | responses: | ||
3800 | '204': | ||
3801 | description: successful operation | ||
3802 | '403': | ||
3803 | description: cannot delete the last password of the protected video | ||
3804 | '400': | ||
3805 | description: video is not password protected | ||
3806 | |||
3731 | /api/v1/video-channels: | 3807 | /api/v1/video-channels: |
3732 | get: | 3808 | get: |
3733 | summary: List video channels | 3809 | summary: List video channels |
@@ -4554,6 +4630,7 @@ paths: | |||
4554 | - $ref: '#/components/parameters/start' | 4630 | - $ref: '#/components/parameters/start' |
4555 | - $ref: '#/components/parameters/count' | 4631 | - $ref: '#/components/parameters/count' |
4556 | - $ref: '#/components/parameters/commentsSort' | 4632 | - $ref: '#/components/parameters/commentsSort' |
4633 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
4557 | responses: | 4634 | responses: |
4558 | '200': | 4635 | '200': |
4559 | description: successful operation | 4636 | description: successful operation |
@@ -4600,6 +4677,7 @@ paths: | |||
4600 | parameters: | 4677 | parameters: |
4601 | - $ref: '#/components/parameters/idOrUUID' | 4678 | - $ref: '#/components/parameters/idOrUUID' |
4602 | - $ref: '#/components/parameters/threadId' | 4679 | - $ref: '#/components/parameters/threadId' |
4680 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
4603 | responses: | 4681 | responses: |
4604 | '200': | 4682 | '200': |
4605 | description: successful operation | 4683 | description: successful operation |
@@ -4618,6 +4696,7 @@ paths: | |||
4618 | parameters: | 4696 | parameters: |
4619 | - $ref: '#/components/parameters/idOrUUID' | 4697 | - $ref: '#/components/parameters/idOrUUID' |
4620 | - $ref: '#/components/parameters/commentId' | 4698 | - $ref: '#/components/parameters/commentId' |
4699 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
4621 | responses: | 4700 | responses: |
4622 | '200': | 4701 | '200': |
4623 | description: successful operation | 4702 | description: successful operation |
@@ -4668,6 +4747,7 @@ paths: | |||
4668 | - Video Rates | 4747 | - Video Rates |
4669 | parameters: | 4748 | parameters: |
4670 | - $ref: '#/components/parameters/idOrUUID' | 4749 | - $ref: '#/components/parameters/idOrUUID' |
4750 | - $ref: '#/components/parameters/videoPasswordHeader' | ||
4671 | requestBody: | 4751 | requestBody: |
4672 | content: | 4752 | content: |
4673 | application/json: | 4753 | application/json: |
@@ -6525,7 +6605,20 @@ components: | |||
6525 | required: true | 6605 | required: true |
6526 | schema: | 6606 | schema: |
6527 | $ref: '#/components/schemas/UUIDv4' | 6607 | $ref: '#/components/schemas/UUIDv4' |
6528 | 6608 | videoPasswordId: | |
6609 | name: videoPasswordId | ||
6610 | in: path | ||
6611 | required: true | ||
6612 | description: The video password id | ||
6613 | schema: | ||
6614 | $ref: '#/components/schemas/id' | ||
6615 | videoPasswordHeader: | ||
6616 | name: x-peertube-video-password | ||
6617 | description: Required on password protected video | ||
6618 | in: header | ||
6619 | required: false | ||
6620 | schema: | ||
6621 | type: string | ||
6529 | securitySchemes: | 6622 | securitySchemes: |
6530 | OAuth2: | 6623 | OAuth2: |
6531 | description: | | 6624 | description: | |
@@ -8228,6 +8321,8 @@ components: | |||
8228 | description: Video preview file | 8321 | description: Video preview file |
8229 | type: string | 8322 | type: string |
8230 | format: binary | 8323 | format: binary |
8324 | videoPasswords: | ||
8325 | $ref: '#/components/schemas/AddVideoPasswords' | ||
8231 | required: | 8326 | required: |
8232 | - channelId | 8327 | - channelId |
8233 | - name | 8328 | - name |
@@ -9616,6 +9711,29 @@ components: | |||
9616 | privatePayload: | 9711 | privatePayload: |
9617 | type: object | 9712 | type: object |
9618 | 9713 | ||
9714 | VideoPassword: | ||
9715 | properties: | ||
9716 | id: | ||
9717 | $ref: '#/components/schemas/id' | ||
9718 | password: | ||
9719 | type: string | ||
9720 | minLength: 2 | ||
9721 | videoId: | ||
9722 | $ref: '#/components/schemas/id' | ||
9723 | VideoPasswordList: | ||
9724 | properties: | ||
9725 | total: | ||
9726 | type: integer | ||
9727 | example: 1 | ||
9728 | data: | ||
9729 | type: array | ||
9730 | items: | ||
9731 | $ref: '#/components/schemas/VideoPassword' | ||
9732 | AddVideoPasswords: | ||
9733 | type: array | ||
9734 | items: | ||
9735 | $ref: "#/components/schemas/VideoPassword/properties/password" | ||
9736 | uniqueItems: true | ||
9619 | callbacks: | 9737 | callbacks: |
9620 | searchIndex: | 9738 | searchIndex: |
9621 | 'https://search.example.org/api/v1/search/videos': | 9739 | 'https://search.example.org/api/v1/search/videos': |