Feature/password protected videos (#5836)
* Add server endpoints * Refactoring test suites * Update server and add openapi documentation * fix compliation and tests * upload/import password protected video on client * add server error code * Add video password to update resolver * add custom message when sharing pw protected video * improve confirm component * Add new alert in component * Add ability to watch protected video on client * Cannot have password protected replay privacy * Add migration * Add tests * update after review * Update check params tests * Add live videos test * Add more filter test * Update static file privacy test * Update object storage tests * Add test on feeds * Add missing word * Fix tests * Fix tests on live videos * add embed support on password protected videos * fix style * Correcting data leaks * Unable to add password protected privacy on replay * Updated code based on review comments * fix validator and command * Updated code based on review comments
This commit is contained in:
parent
ae22c59f14
commit
40346ead2b
|
@ -151,7 +151,7 @@ export class VideoAdminService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.excludePublic) {
|
if (filters.excludePublic) {
|
||||||
privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ]
|
privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
|
||||||
|
|
||||||
filters.excludePublic = undefined
|
filters.excludePublic = undefined
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
|
||||||
async disableTwoFactor () {
|
async disableTwoFactor () {
|
||||||
const message = $localize`Are you sure you want to disable two factor authentication of your account?`
|
const message = $localize`Are you sure you want to disable two factor authentication of your account?`
|
||||||
|
|
||||||
const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
|
const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` })
|
||||||
if (confirmed === false) return
|
if (confirmed === false) return
|
||||||
|
|
||||||
this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
|
this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
|
||||||
|
|
|
@ -120,7 +120,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="schedulePublicationEnabled" class="form-group">
|
<div *ngIf="passwordProtectionSelected" class="form-group">
|
||||||
|
<label i18n for="videoPassword">Password</label>
|
||||||
|
<my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="schedulePublicationSelected" class="form-group">
|
||||||
<label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
|
<label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
|
||||||
<p-calendar
|
<p-calendar
|
||||||
id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
|
id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
|
||||||
|
@ -287,7 +292,7 @@
|
||||||
<div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
|
<div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
|
||||||
<label i18n for="replayPrivacy">Privacy of the new replay</label>
|
<label i18n for="replayPrivacy">Privacy of the new replay</label>
|
||||||
<my-select-options
|
<my-select-options
|
||||||
labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy"
|
labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
|
||||||
></my-select-options>
|
></my-select-options>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
VIDEO_LICENCE_VALIDATOR,
|
VIDEO_LICENCE_VALIDATOR,
|
||||||
VIDEO_NAME_VALIDATOR,
|
VIDEO_NAME_VALIDATOR,
|
||||||
VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
|
VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
|
||||||
|
VIDEO_PASSWORD_VALIDATOR,
|
||||||
VIDEO_PRIVACY_VALIDATOR,
|
VIDEO_PRIVACY_VALIDATOR,
|
||||||
VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
|
VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
|
||||||
VIDEO_SUPPORT_VALIDATOR,
|
VIDEO_SUPPORT_VALIDATOR,
|
||||||
|
@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
// So that it can be accessed in the template
|
// So that it can be accessed in the template
|
||||||
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
|
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
|
||||||
|
|
||||||
videoPrivacies: VideoConstant<VideoPrivacy>[] = []
|
videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = []
|
||||||
|
replayPrivacies: VideoConstant<VideoPrivacy> [] = []
|
||||||
videoCategories: VideoConstant<number>[] = []
|
videoCategories: VideoConstant<number>[] = []
|
||||||
videoLicences: VideoConstant<number>[] = []
|
videoLicences: VideoConstant<number>[] = []
|
||||||
videoLanguages: VideoLanguages[] = []
|
videoLanguages: VideoLanguages[] = []
|
||||||
|
@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
pluginDataFormGroup: FormGroup
|
pluginDataFormGroup: FormGroup
|
||||||
|
|
||||||
schedulePublicationEnabled = false
|
schedulePublicationSelected = false
|
||||||
|
passwordProtectionSelected = false
|
||||||
|
|
||||||
calendarLocale: any = {}
|
calendarLocale: any = {}
|
||||||
minScheduledDate = new Date()
|
minScheduledDate = new Date()
|
||||||
|
@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
const obj: { [ id: string ]: BuildFormValidator } = {
|
const obj: { [ id: string ]: BuildFormValidator } = {
|
||||||
name: VIDEO_NAME_VALIDATOR,
|
name: VIDEO_NAME_VALIDATOR,
|
||||||
privacy: VIDEO_PRIVACY_VALIDATOR,
|
privacy: VIDEO_PRIVACY_VALIDATOR,
|
||||||
|
videoPassword: VIDEO_PASSWORD_VALIDATOR,
|
||||||
channelId: VIDEO_CHANNEL_VALIDATOR,
|
channelId: VIDEO_CHANNEL_VALIDATOR,
|
||||||
nsfw: null,
|
nsfw: null,
|
||||||
commentsEnabled: null,
|
commentsEnabled: null,
|
||||||
|
@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.serverService.getVideoPrivacies()
|
this.serverService.getVideoPrivacies()
|
||||||
.subscribe(privacies => {
|
.subscribe(privacies => {
|
||||||
this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
|
const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
|
||||||
|
this.videoPrivacies = videoPrivacies
|
||||||
|
this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED)
|
||||||
|
|
||||||
// Can't schedule publication if private privacy is not available (could be deleted by a plugin)
|
// Can't schedule publication if private privacy is not available (could be deleted by a plugin)
|
||||||
const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
|
const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
|
||||||
|
@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
.subscribe(
|
.subscribe(
|
||||||
newPrivacyId => {
|
newPrivacyId => {
|
||||||
|
|
||||||
this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
|
this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
|
||||||
|
|
||||||
// Value changed
|
// Value changed
|
||||||
const scheduleControl = this.form.get('schedulePublicationAt')
|
const scheduleControl = this.form.get('schedulePublicationAt')
|
||||||
const waitTranscodingControl = this.form.get('waitTranscoding')
|
const waitTranscodingControl = this.form.get('waitTranscoding')
|
||||||
|
|
||||||
if (this.schedulePublicationEnabled) {
|
if (this.schedulePublicationSelected) {
|
||||||
scheduleControl.setValidators([ Validators.required ])
|
scheduleControl.setValidators([ Validators.required ])
|
||||||
|
|
||||||
waitTranscodingControl.disable()
|
waitTranscodingControl.disable()
|
||||||
|
@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.firstPatchDone = true
|
this.firstPatchDone = true
|
||||||
|
|
||||||
|
this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
const videoPasswordControl = this.form.get('videoPassword')
|
||||||
|
|
||||||
|
if (this.passwordProtectionSelected) {
|
||||||
|
videoPasswordControl.setValidators([ Validators.required ])
|
||||||
|
} else {
|
||||||
|
videoPasswordControl.clearValidators()
|
||||||
|
}
|
||||||
|
videoPasswordControl.updateValueAndValidity()
|
||||||
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
||||||
this.buildForm({})
|
this.buildForm({})
|
||||||
|
|
||||||
const { videoData } = this.route.snapshot.data
|
const { videoData } = this.route.snapshot.data
|
||||||
const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData
|
const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
|
||||||
|
|
||||||
this.videoDetails = video
|
this.videoDetails = video
|
||||||
this.videoEdit = new VideoEdit(this.videoDetails)
|
this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
|
||||||
|
|
||||||
this.userVideoChannels = videoChannels
|
this.userVideoChannels = videoChannels
|
||||||
this.videoCaptions = videoCaptions
|
this.videoCaptions = videoCaptions
|
||||||
|
|
|
@ -4,8 +4,9 @@ import { Injectable } from '@angular/core'
|
||||||
import { ActivatedRouteSnapshot } from '@angular/router'
|
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||||
import { AuthService } from '@app/core'
|
import { AuthService } from '@app/core'
|
||||||
import { listUserChannelsForSelect } from '@app/helpers'
|
import { listUserChannelsForSelect } from '@app/helpers'
|
||||||
import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
|
import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main'
|
||||||
import { LiveVideoService } from '@app/shared/shared-video-live'
|
import { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
|
import { VideoPrivacy } from '@shared/models/videos'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoUpdateResolver {
|
export class VideoUpdateResolver {
|
||||||
|
@ -13,7 +14,8 @@ export class VideoUpdateResolver {
|
||||||
private videoService: VideoService,
|
private videoService: VideoService,
|
||||||
private liveVideoService: LiveVideoService,
|
private liveVideoService: LiveVideoService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private videoCaptionService: VideoCaptionService
|
private videoCaptionService: VideoCaptionService,
|
||||||
|
private videoPasswordService: VideoPasswordService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,11 +23,11 @@ export class VideoUpdateResolver {
|
||||||
const uuid: string = route.params['uuid']
|
const uuid: string = route.params['uuid']
|
||||||
|
|
||||||
return this.videoService.getVideo({ videoId: uuid })
|
return this.videoService.getVideo({ videoId: uuid })
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(video => forkJoin(this.buildVideoObservables(video))),
|
switchMap(video => forkJoin(this.buildVideoObservables(video))),
|
||||||
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) =>
|
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
|
||||||
({ video, videoChannels, videoCaptions, videoSource, liveVideo }))
|
({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildVideoObservables (video: VideoDetails) {
|
private buildVideoObservables (video: VideoDetails) {
|
||||||
|
@ -46,6 +48,10 @@ export class VideoUpdateResolver {
|
||||||
|
|
||||||
video.isLive
|
video.isLive
|
||||||
? this.liveVideoService.getVideoLive(video.id)
|
? this.liveVideoService.getVideoLive(video.id)
|
||||||
|
: of(undefined),
|
||||||
|
|
||||||
|
video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
|
||||||
: of(undefined)
|
: of(undefined)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="video-actions-rates">
|
<div class="video-actions-rates">
|
||||||
<div class="video-actions justify-content-end">
|
<div class="video-actions justify-content-end">
|
||||||
<my-video-rate
|
<my-video-rate
|
||||||
[video]="video" [isUserLoggedIn]="isUserLoggedIn"
|
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
|
||||||
(rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
|
(rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
|
||||||
></my-video-rate>
|
></my-video-rate>
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
|
class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
|
||||||
*ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)"
|
*ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
|
||||||
[ngbTooltip]="tooltipSaveToPlaylist"
|
[ngbTooltip]="tooltipSaveToPlaylist"
|
||||||
placement="bottom auto"
|
placement="bottom auto"
|
||||||
>
|
>
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
<span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
|
<span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<my-video-download #videoDownloadModal></my-video-download>
|
<my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container *ngIf="isUserLoggedIn">
|
<ng-container *ngIf="isUserLoggedIn">
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal'
|
||||||
import { SupportModalComponent } from '@app/shared/shared-support-modal'
|
import { SupportModalComponent } from '@app/shared/shared-support-modal'
|
||||||
import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
|
import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
|
||||||
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
|
import { VideoPlaylist } from '@app/shared/shared-video-playlist'
|
||||||
import { UserVideoRateType, VideoCaption } from '@shared/models/videos'
|
import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-action-buttons',
|
selector: 'my-action-buttons',
|
||||||
|
@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
@ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
|
||||||
|
|
||||||
@Input() video: VideoDetails
|
@Input() video: VideoDetails
|
||||||
|
@Input() videoPassword: string
|
||||||
@Input() videoCaptions: VideoCaption[]
|
@Input() videoCaptions: VideoCaption[]
|
||||||
@Input() playlist: VideoPlaylist
|
@Input() playlist: VideoPlaylist
|
||||||
|
|
||||||
@Input() isUserLoggedIn: boolean
|
@Input() isUserLoggedIn: boolean
|
||||||
|
@Input() isUserOwner: boolean
|
||||||
|
|
||||||
@Input() currentTime: number
|
@Input() currentTime: number
|
||||||
@Input() currentPlaylistPosition: number
|
@Input() currentPlaylistPosition: number
|
||||||
|
@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
private setVideoLikesBarTooltipText () {
|
private setVideoLikesBarTooltipText () {
|
||||||
this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
|
this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoAddableToPlaylist () {
|
||||||
|
const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
|
||||||
|
if (!this.isUserLoggedIn) return false
|
||||||
|
|
||||||
|
if (isPasswordProtected) return this.isUserOwner
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models'
|
||||||
})
|
})
|
||||||
export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
|
export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
@Input() video: VideoDetails
|
@Input() video: VideoDetails
|
||||||
|
@Input() videoPassword: string
|
||||||
@Input() isUserLoggedIn: boolean
|
@Input() isUserLoggedIn: boolean
|
||||||
|
|
||||||
@Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
|
@Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
|
||||||
|
@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setRating (nextRating: UserVideoRateType) {
|
private setRating (nextRating: UserVideoRateType) {
|
||||||
const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = {
|
const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = {
|
||||||
like: this.videoService.setVideoLike,
|
like: this.videoService.setVideoLike,
|
||||||
dislike: this.videoService.setVideoDislike,
|
dislike: this.videoService.setVideoDislike,
|
||||||
none: this.videoService.unsetVideoLike
|
none: this.videoService.unsetVideoLike
|
||||||
}
|
}
|
||||||
|
|
||||||
ratingMethods[nextRating].call(this.videoService, this.video.uuid)
|
ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
// Update the video like attribute
|
// Update the video like attribute
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models'
|
||||||
export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
|
export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
|
||||||
@Input() user: User
|
@Input() user: User
|
||||||
@Input() video: Video
|
@Input() video: Video
|
||||||
|
@Input() videoPassword: string
|
||||||
@Input() parentComment?: VideoComment
|
@Input() parentComment?: VideoComment
|
||||||
@Input() parentComments?: VideoComment[]
|
@Input() parentComments?: VideoComment[]
|
||||||
@Input() focusOnInit = false
|
@Input() focusOnInit = false
|
||||||
|
@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
|
||||||
|
|
||||||
private addCommentReply (commentCreate: VideoCommentCreate) {
|
private addCommentReply (commentCreate: VideoCommentCreate) {
|
||||||
return this.videoCommentService
|
return this.videoCommentService
|
||||||
.addCommentReply(this.video.uuid, this.parentComment.id, commentCreate)
|
.addCommentReply({
|
||||||
|
videoId: this.video.uuid,
|
||||||
|
inReplyToCommentId: this.parentComment.id,
|
||||||
|
comment: commentCreate,
|
||||||
|
videoPassword: this.videoPassword
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private addCommentThread (commentCreate: VideoCommentCreate) {
|
private addCommentThread (commentCreate: VideoCommentCreate) {
|
||||||
return this.videoCommentService
|
return this.videoCommentService
|
||||||
.addCommentThread(this.video.uuid, commentCreate)
|
.addCommentThread(this.video.uuid, commentCreate, this.videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
private initTextValue () {
|
private initTextValue () {
|
||||||
|
|
|
@ -62,6 +62,7 @@
|
||||||
*ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
|
*ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[parentComment]="comment"
|
[parentComment]="comment"
|
||||||
[parentComments]="newParentComments"
|
[parentComments]="newParentComments"
|
||||||
[focusOnInit]="true"
|
[focusOnInit]="true"
|
||||||
|
@ -75,6 +76,7 @@
|
||||||
<my-video-comment
|
<my-video-comment
|
||||||
[comment]="commentChild.comment"
|
[comment]="commentChild.comment"
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[inReplyToCommentId]="inReplyToCommentId"
|
[inReplyToCommentId]="inReplyToCommentId"
|
||||||
[commentTree]="commentChild"
|
[commentTree]="commentChild"
|
||||||
[parentComments]="newParentComments"
|
[parentComments]="newParentComments"
|
||||||
|
|
|
@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
||||||
@ViewChild('commentReportModal') commentReportModal: CommentReportComponent
|
@ViewChild('commentReportModal') commentReportModal: CommentReportComponent
|
||||||
|
|
||||||
@Input() video: Video
|
@Input() video: Video
|
||||||
|
@Input() videoPassword: string
|
||||||
@Input() comment: VideoComment
|
@Input() comment: VideoComment
|
||||||
@Input() parentComments: VideoComment[] = []
|
@Input() parentComments: VideoComment[] = []
|
||||||
@Input() commentTree: VideoCommentThreadTree
|
@Input() commentTree: VideoCommentThreadTree
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
<ng-template [ngIf]="video.commentsEnabled === true">
|
<ng-template [ngIf]="video.commentsEnabled === true">
|
||||||
<my-video-comment-add
|
<my-video-comment-add
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
(commentCreated)="onCommentThreadCreated($event)"
|
(commentCreated)="onCommentThreadCreated($event)"
|
||||||
[textValue]="commentThreadRedraftValue"
|
[textValue]="commentThreadRedraftValue"
|
||||||
|
@ -34,6 +35,7 @@
|
||||||
*ngIf="highlightedThread"
|
*ngIf="highlightedThread"
|
||||||
[comment]="highlightedThread"
|
[comment]="highlightedThread"
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[inReplyToCommentId]="inReplyToCommentId"
|
[inReplyToCommentId]="inReplyToCommentId"
|
||||||
[commentTree]="threadComments[highlightedThread.id]"
|
[commentTree]="threadComments[highlightedThread.id]"
|
||||||
[highlightedComment]="true"
|
[highlightedComment]="true"
|
||||||
|
@ -53,6 +55,7 @@
|
||||||
*ngIf="!highlightedThread || comment.id !== highlightedThread.id"
|
*ngIf="!highlightedThread || comment.id !== highlightedThread.id"
|
||||||
[comment]="comment"
|
[comment]="comment"
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[inReplyToCommentId]="inReplyToCommentId"
|
[inReplyToCommentId]="inReplyToCommentId"
|
||||||
[commentTree]="threadComments[comment.id]"
|
[commentTree]="threadComments[comment.id]"
|
||||||
[firstInThread]="i + 1 !== comments.length"
|
[firstInThread]="i + 1 !== comments.length"
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
|
||||||
export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
@ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
|
@ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
|
||||||
@Input() video: VideoDetails
|
@Input() video: VideoDetails
|
||||||
|
@Input() videoPassword: string
|
||||||
@Input() user: User
|
@Input() user: User
|
||||||
|
|
||||||
@Output() timestampClicked = new EventEmitter<number>()
|
@Output() timestampClicked = new EventEmitter<number>()
|
||||||
|
@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
videoId: this.video.uuid,
|
videoId: this.video.uuid,
|
||||||
threadId: commentId
|
threadId: commentId,
|
||||||
|
videoPassword: this.videoPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
const obs = this.hooks.wrapObsFun(
|
const obs = this.hooks.wrapObsFun(
|
||||||
|
@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
loadMoreThreads () {
|
loadMoreThreads () {
|
||||||
const params = {
|
const params = {
|
||||||
videoId: this.video.uuid,
|
videoId: this.video.uuid,
|
||||||
|
videoPassword: this.videoPassword,
|
||||||
componentPagination: this.componentPagination,
|
componentPagination: this.componentPagination,
|
||||||
sort: this.sort
|
sort: this.sort
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,3 +42,7 @@
|
||||||
<div class="blocked-label" i18n>This video is blocked.</div>
|
<div class="blocked-label" i18n>This video is blocked.</div>
|
||||||
{{ video.blacklistedReason }}
|
{{ video.blacklistedReason }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)">
|
||||||
|
This video is password protected.
|
||||||
|
</div>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, Input } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
|
import { AuthUser } from '@app/core'
|
||||||
import { VideoDetails } from '@app/shared/shared-main'
|
import { VideoDetails } from '@app/shared/shared-main'
|
||||||
import { VideoState } from '@shared/models'
|
import { VideoPrivacy, VideoState } from '@shared/models'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-alert',
|
selector: 'my-video-alert',
|
||||||
|
@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
|
||||||
styleUrls: [ './video-alert.component.scss' ]
|
styleUrls: [ './video-alert.component.scss' ]
|
||||||
})
|
})
|
||||||
export class VideoAlertComponent {
|
export class VideoAlertComponent {
|
||||||
|
@Input() user: AuthUser
|
||||||
@Input() video: VideoDetails
|
@Input() video: VideoDetails
|
||||||
@Input() noPlaylistVideoFound: boolean
|
@Input() noPlaylistVideoFound: boolean
|
||||||
|
|
||||||
|
@ -46,4 +48,8 @@ export class VideoAlertComponent {
|
||||||
isLiveEnded () {
|
isLiveEnded () {
|
||||||
return this.video?.state.id === VideoState.LIVE_ENDED
|
return this.video?.state.id === VideoState.LIVE_ENDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoPasswordProtected () {
|
||||||
|
return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
<my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
|
<my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
|
||||||
|
|
||||||
<!-- Video information -->
|
<!-- Video information -->
|
||||||
<div *ngIf="video" class="margin-content video-bottom">
|
<div *ngIf="video" class="margin-content video-bottom">
|
||||||
|
@ -51,8 +51,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<my-action-buttons
|
<my-action-buttons
|
||||||
[video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist"
|
[video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
|
||||||
[currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
|
[playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
|
||||||
></my-action-buttons>
|
></my-action-buttons>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -92,6 +92,7 @@
|
||||||
<my-video-comments
|
<my-video-comments
|
||||||
class="border-top"
|
class="border-top"
|
||||||
[video]="video"
|
[video]="video"
|
||||||
|
[videoPassword]="videoPassword"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
(timestampClicked)="handleTimestampClicked($event)"
|
(timestampClicked)="handleTimestampClicked($event)"
|
||||||
></my-video-comments>
|
></my-video-comments>
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
|
||||||
import { LiveVideoService } from '@app/shared/shared-video-live'
|
import { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
|
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video'
|
import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video'
|
||||||
import { timeToInt } from '@shared/core-utils'
|
import { timeToInt } from '@shared/core-utils'
|
||||||
import {
|
import {
|
||||||
HTMLServerConfig,
|
HTMLServerConfig,
|
||||||
|
@ -68,6 +68,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video: VideoDetails = null
|
video: VideoDetails = null
|
||||||
videoCaptions: VideoCaption[] = []
|
videoCaptions: VideoCaption[] = []
|
||||||
liveVideo: LiveVideo
|
liveVideo: LiveVideo
|
||||||
|
videoPassword: string
|
||||||
|
|
||||||
playlistPosition: number
|
playlistPosition: number
|
||||||
playlist: VideoPlaylist = null
|
playlist: VideoPlaylist = null
|
||||||
|
@ -191,6 +192,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
return this.authService.isLoggedIn()
|
return this.authService.isLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isUserOwner () {
|
||||||
|
return this.video.isLocal === true && this.video.account.name === this.user?.username
|
||||||
|
}
|
||||||
|
|
||||||
isVideoBlur (video: Video) {
|
isVideoBlur (video: Video) {
|
||||||
return video.isVideoNSFWForUser(this.user, this.serverConfig)
|
return video.isVideoNSFWForUser(this.user, this.serverConfig)
|
||||||
}
|
}
|
||||||
|
@ -243,8 +248,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
private loadVideo (options: {
|
private loadVideo (options: {
|
||||||
videoId: string
|
videoId: string
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
|
videoPassword?: string
|
||||||
}) {
|
}) {
|
||||||
const { videoId, forceAutoplay } = options
|
const { videoId, forceAutoplay, videoPassword } = options
|
||||||
|
|
||||||
if (this.isSameElement(this.video, videoId)) return
|
if (this.isSameElement(this.video, videoId)) return
|
||||||
|
|
||||||
|
@ -254,7 +260,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
const videoObs = this.hooks.wrapObsFun(
|
const videoObs = this.hooks.wrapObsFun(
|
||||||
this.videoService.getVideo.bind(this.videoService),
|
this.videoService.getVideo.bind(this.videoService),
|
||||||
{ videoId },
|
{ videoId, videoPassword },
|
||||||
'video-watch',
|
'video-watch',
|
||||||
'filter:api.video-watch.video.get.params',
|
'filter:api.video-watch.video.get.params',
|
||||||
'filter:api.video-watch.video.get.result'
|
'filter:api.video-watch.video.get.result'
|
||||||
|
@ -269,16 +275,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
switchMap(({ video, live }) => {
|
switchMap(({ video, live }) => {
|
||||||
if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined })
|
if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined })
|
||||||
|
|
||||||
return this.videoFileTokenService.getVideoFileToken(video.uuid)
|
return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword })
|
||||||
.pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
|
.pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
forkJoin([
|
forkJoin([
|
||||||
videoAndLiveObs,
|
videoAndLiveObs,
|
||||||
this.videoCaptionService.listCaptions(videoId),
|
this.videoCaptionService.listCaptions(videoId, videoPassword),
|
||||||
this.userService.getAnonymousOrLoggedUser()
|
this.userService.getAnonymousOrLoggedUser()
|
||||||
]).subscribe({
|
]).subscribe({
|
||||||
next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
|
next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
|
||||||
|
@ -304,13 +310,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
live,
|
live,
|
||||||
videoCaptions: captionsResult.data,
|
videoCaptions: captionsResult.data,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
|
videoPassword,
|
||||||
loggedInOrAnonymousUser,
|
loggedInOrAnonymousUser,
|
||||||
urlOptions,
|
urlOptions,
|
||||||
forceAutoplay
|
forceAutoplay
|
||||||
}).catch(err => this.handleGlobalError(err))
|
}).catch(err => {
|
||||||
|
this.handleGlobalError(err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
error: async err => {
|
||||||
|
if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
|
||||||
|
const { confirmed, password } = await this.handleVideoPasswordError(err)
|
||||||
|
|
||||||
error: err => this.handleRequestError(err)
|
if (confirmed === false) return this.location.back()
|
||||||
|
|
||||||
|
this.loadVideo({ ...options, videoPassword: password })
|
||||||
|
} else {
|
||||||
|
this.handleRequestError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -375,17 +393,35 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.notifier.error(errorMessage)
|
this.notifier.error(errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleVideoPasswordError (err: any) {
|
||||||
|
let isIncorrectPassword: boolean
|
||||||
|
|
||||||
|
if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) {
|
||||||
|
isIncorrectPassword = false
|
||||||
|
} else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
|
||||||
|
this.videoPassword = undefined
|
||||||
|
isIncorrectPassword = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.confirmService.confirmWithPassword({
|
||||||
|
message: $localize`You need a password to watch this video`,
|
||||||
|
title: $localize`This video is password protected`,
|
||||||
|
errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async onVideoFetched (options: {
|
private async onVideoFetched (options: {
|
||||||
video: VideoDetails
|
video: VideoDetails
|
||||||
live: LiveVideo
|
live: LiveVideo
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
|
videoPassword: string
|
||||||
|
|
||||||
urlOptions: URLOptions
|
urlOptions: URLOptions
|
||||||
loggedInOrAnonymousUser: User
|
loggedInOrAnonymousUser: User
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
}) {
|
}) {
|
||||||
const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options
|
const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options
|
||||||
|
|
||||||
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
||||||
|
|
||||||
|
@ -393,6 +429,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.videoCaptions = videoCaptions
|
this.videoCaptions = videoCaptions
|
||||||
this.liveVideo = live
|
this.liveVideo = live
|
||||||
this.videoFileToken = videoFileToken
|
this.videoFileToken = videoFileToken
|
||||||
|
this.videoPassword = videoPassword
|
||||||
|
|
||||||
// Re init attributes
|
// Re init attributes
|
||||||
this.playerPlaceholderImgSrc = undefined
|
this.playerPlaceholderImgSrc = undefined
|
||||||
|
@ -450,6 +487,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoCaptions: this.videoCaptions,
|
videoCaptions: this.videoCaptions,
|
||||||
liveVideo: this.liveVideo,
|
liveVideo: this.liveVideo,
|
||||||
videoFileToken: this.videoFileToken,
|
videoFileToken: this.videoFileToken,
|
||||||
|
videoPassword: this.videoPassword,
|
||||||
urlOptions,
|
urlOptions,
|
||||||
loggedInOrAnonymousUser,
|
loggedInOrAnonymousUser,
|
||||||
forceAutoplay,
|
forceAutoplay,
|
||||||
|
@ -600,6 +638,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
|
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
|
videoPassword: string
|
||||||
|
|
||||||
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
||||||
|
|
||||||
|
@ -607,7 +646,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
user?: AuthUser // Keep for plugins
|
user?: AuthUser // Keep for plugins
|
||||||
}) {
|
}) {
|
||||||
const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
|
const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
|
||||||
|
|
||||||
const getStartTime = () => {
|
const getStartTime = () => {
|
||||||
const byUrl = urlOptions.startTime !== undefined
|
const byUrl = urlOptions.startTime !== undefined
|
||||||
|
@ -689,7 +728,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
serverUrl: environment.originServerUrl || window.location.origin,
|
serverUrl: environment.originServerUrl || window.location.origin,
|
||||||
|
|
||||||
videoFileToken: () => videoFileToken,
|
videoFileToken: () => videoFileToken,
|
||||||
requiresAuth: videoRequiresAuth(video),
|
requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
|
||||||
|
requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
|
||||||
|
!video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
|
||||||
|
videoPassword: () => videoPassword,
|
||||||
|
|
||||||
videoCaptions: playerCaptions,
|
videoCaptions: playerCaptions,
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
|
||||||
type ConfirmOptions = {
|
type ConfirmOptions = {
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
errorMessage?: string
|
||||||
} & (
|
} & (
|
||||||
{
|
{
|
||||||
type: 'confirm'
|
type: 'confirm'
|
||||||
|
@ -12,6 +13,7 @@ type ConfirmOptions = {
|
||||||
{
|
{
|
||||||
type: 'confirm-password'
|
type: 'confirm-password'
|
||||||
confirmButtonText?: string
|
confirmButtonText?: string
|
||||||
|
isIncorrectPassword?: boolean
|
||||||
} |
|
} |
|
||||||
{
|
{
|
||||||
type: 'confirm-expected-input'
|
type: 'confirm-expected-input'
|
||||||
|
@ -32,8 +34,14 @@ export class ConfirmService {
|
||||||
return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
|
return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
|
confirmWithPassword (options: {
|
||||||
this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
|
message: string
|
||||||
|
title?: string
|
||||||
|
confirmButtonText?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}) {
|
||||||
|
const { message, title = '', confirmButtonText, errorMessage } = options
|
||||||
|
this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage })
|
||||||
|
|
||||||
const obs = this.confirmResponse.asObservable()
|
const obs = this.confirmResponse.asObservable()
|
||||||
.pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
|
.pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
|
||||||
|
|
|
@ -12,10 +12,12 @@
|
||||||
<div *ngIf="inputLabel" class="form-group mt-3">
|
<div *ngIf="inputLabel" class="form-group mt-3">
|
||||||
<label for="confirmInput">{{ inputLabel }}</label>
|
<label for="confirmInput">{{ inputLabel }}</label>
|
||||||
|
|
||||||
<input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
|
<input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" />
|
||||||
|
|
||||||
<my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
|
<my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer inputs">
|
<div class="modal-footer inputs">
|
||||||
|
|
|
@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
|
||||||
inputValue = ''
|
inputValue = ''
|
||||||
confirmButtonText = ''
|
confirmButtonText = ''
|
||||||
|
|
||||||
|
errorMessage = ''
|
||||||
|
|
||||||
isPasswordInput = false
|
isPasswordInput = false
|
||||||
|
|
||||||
private openedModal: NgbModalRef
|
private openedModal: NgbModalRef
|
||||||
|
@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit {
|
||||||
this.inputValue = ''
|
this.inputValue = ''
|
||||||
this.confirmButtonText = ''
|
this.confirmButtonText = ''
|
||||||
this.isPasswordInput = false
|
this.isPasswordInput = false
|
||||||
|
this.errorMessage = ''
|
||||||
|
|
||||||
const { type, title, message, confirmButtonText } = payload
|
const { type, title, message, confirmButtonText, errorMessage } = payload
|
||||||
|
|
||||||
this.title = title
|
this.title = title
|
||||||
|
|
||||||
|
@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit {
|
||||||
} else if (type === 'confirm-password') {
|
} else if (type === 'confirm-password') {
|
||||||
this.inputLabel = $localize`Confirm your password`
|
this.inputLabel = $localize`Confirm your password`
|
||||||
this.isPasswordInput = true
|
this.isPasswordInput = true
|
||||||
|
this.errorMessage = errorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
this.confirmButtonText = confirmButtonText || $localize`Confirm`
|
this.confirmButtonText = confirmButtonText || $localize`Confirm`
|
||||||
|
@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit {
|
||||||
return this.expectedInputValue !== this.inputValue
|
return this.expectedInputValue !== this.inputValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasError () {
|
||||||
|
return this.errorMessage
|
||||||
|
}
|
||||||
showModal () {
|
showModal () {
|
||||||
this.inputValue = ''
|
this.inputValue = ''
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = {
|
||||||
|
VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
|
||||||
|
MESSAGES: {
|
||||||
|
minLength: $localize`A password should be at least 2 characters long.`,
|
||||||
|
maxLength: $localize`A password should be shorter than 100 characters long.`,
|
||||||
|
required: $localize`A password is required for password protected video.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
|
export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
|
||||||
VALIDATORS: [ ],
|
VALIDATORS: [ ],
|
||||||
MESSAGES: {}
|
MESSAGES: {}
|
||||||
|
|
|
@ -52,6 +52,7 @@ import {
|
||||||
VideoFileTokenService,
|
VideoFileTokenService,
|
||||||
VideoImportService,
|
VideoImportService,
|
||||||
VideoOwnershipService,
|
VideoOwnershipService,
|
||||||
|
VideoPasswordService,
|
||||||
VideoResolver,
|
VideoResolver,
|
||||||
VideoService
|
VideoService
|
||||||
} from './video'
|
} from './video'
|
||||||
|
@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel'
|
||||||
|
|
||||||
VideoChannelService,
|
VideoChannelService,
|
||||||
|
|
||||||
|
VideoPasswordService,
|
||||||
|
|
||||||
CustomPageService,
|
CustomPageService,
|
||||||
|
|
||||||
ActorRedirectGuard
|
ActorRedirectGuard
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { RestExtractor, ServerService } from '@app/core'
|
import { RestExtractor, ServerService } from '@app/core'
|
||||||
import { objectToFormData, sortBy } from '@app/helpers'
|
import { objectToFormData, sortBy } from '@app/helpers'
|
||||||
import { VideoService } from '@app/shared/shared-main/video'
|
import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
|
||||||
import { peertubeTranslate } from '@shared/core-utils/i18n'
|
import { peertubeTranslate } from '@shared/core-utils/i18n'
|
||||||
import { ResultList, VideoCaption } from '@shared/models'
|
import { ResultList, VideoCaption } from '@shared/models'
|
||||||
import { environment } from '../../../../environments/environment'
|
import { environment } from '../../../../environments/environment'
|
||||||
|
@ -18,8 +18,10 @@ export class VideoCaptionService {
|
||||||
private restExtractor: RestExtractor
|
private restExtractor: RestExtractor
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listCaptions (videoId: string): Observable<ResultList<VideoCaption>> {
|
listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> {
|
||||||
return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`)
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
|
return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(captionsResult => {
|
switchMap(captionsResult => {
|
||||||
return this.serverService.getServerLocale()
|
return this.serverService.getServerLocale()
|
||||||
|
|
|
@ -5,6 +5,7 @@ export * from './video-edit.model'
|
||||||
export * from './video-file-token.service'
|
export * from './video-file-token.service'
|
||||||
export * from './video-import.service'
|
export * from './video-import.service'
|
||||||
export * from './video-ownership.service'
|
export * from './video-ownership.service'
|
||||||
|
export * from './video-password.service'
|
||||||
export * from './video.model'
|
export * from './video.model'
|
||||||
export * from './video.resolver'
|
export * from './video.resolver'
|
||||||
export * from './video.service'
|
export * from './video.service'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { getAbsoluteAPIUrl } from '@app/helpers'
|
import { getAbsoluteAPIUrl } from '@app/helpers'
|
||||||
import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
|
import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
|
||||||
import { VideoDetails } from './video-details.model'
|
import { VideoDetails } from './video-details.model'
|
||||||
import { objectKeysTyped } from '@shared/core-utils'
|
import { objectKeysTyped } from '@shared/core-utils'
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate {
|
||||||
waitTranscoding: boolean
|
waitTranscoding: boolean
|
||||||
channelId: number
|
channelId: number
|
||||||
privacy: VideoPrivacy
|
privacy: VideoPrivacy
|
||||||
|
videoPassword?: string
|
||||||
support: string
|
support: string
|
||||||
thumbnailfile?: any
|
thumbnailfile?: any
|
||||||
previewfile?: any
|
previewfile?: any
|
||||||
|
@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate {
|
||||||
|
|
||||||
pluginData?: any
|
pluginData?: any
|
||||||
|
|
||||||
constructor (video?: VideoDetails) {
|
constructor (video?: VideoDetails, videoPassword?: VideoPassword) {
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
this.id = video.id
|
this.id = video.id
|
||||||
|
@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate {
|
||||||
: null
|
: null
|
||||||
|
|
||||||
this.pluginData = video.pluginData
|
this.pluginData = video.pluginData
|
||||||
|
|
||||||
|
if (videoPassword) this.videoPassword = videoPassword.password
|
||||||
}
|
}
|
||||||
|
|
||||||
patch (values: { [ id: string ]: any }) {
|
patch (values: { [ id: string ]: any }) {
|
||||||
|
@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate {
|
||||||
waitTranscoding: this.waitTranscoding,
|
waitTranscoding: this.waitTranscoding,
|
||||||
channelId: this.channelId,
|
channelId: this.channelId,
|
||||||
privacy: this.privacy,
|
privacy: this.privacy,
|
||||||
|
videoPassword: this.videoPassword,
|
||||||
originallyPublishedAt: this.originallyPublishedAt
|
originallyPublishedAt: this.originallyPublishedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
|
||||||
import { RestExtractor } from '@app/core'
|
import { RestExtractor } from '@app/core'
|
||||||
import { VideoToken } from '@shared/models'
|
import { VideoToken } from '@shared/models'
|
||||||
import { VideoService } from './video.service'
|
import { VideoService } from './video.service'
|
||||||
|
import { VideoPasswordService } from './video-password.service'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoFileTokenService {
|
export class VideoFileTokenService {
|
||||||
|
@ -15,16 +16,18 @@ export class VideoFileTokenService {
|
||||||
private restExtractor: RestExtractor
|
private restExtractor: RestExtractor
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getVideoFileToken (videoUUID: string) {
|
getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
|
||||||
const existing = this.store.get(videoUUID)
|
const existing = this.store.get(videoUUID)
|
||||||
if (existing) return of(existing)
|
if (existing) return of(existing)
|
||||||
|
|
||||||
return this.createVideoFileToken(videoUUID)
|
return this.createVideoFileToken(videoUUID, videoPassword)
|
||||||
.pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
|
.pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
|
||||||
}
|
}
|
||||||
|
|
||||||
private createVideoFileToken (videoUUID: string) {
|
private createVideoFileToken (videoUUID: string, videoPassword?: string) {
|
||||||
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {})
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
|
return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(({ files }) => files),
|
map(({ files }) => files),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ResultList, VideoPassword } from '@shared/models'
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { catchError, switchMap } from 'rxjs'
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http'
|
||||||
|
import { RestExtractor } from '@app/core'
|
||||||
|
import { VideoService } from './video.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VideoPasswordService {
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private authHttp: HttpClient,
|
||||||
|
private restExtractor: RestExtractor
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static buildVideoPasswordHeader (videoPassword: string) {
|
||||||
|
return videoPassword
|
||||||
|
? new HttpHeaders().set('x-peertube-video-password', videoPassword)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoPasswords (options: { videoUUID: string }) {
|
||||||
|
return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`)
|
||||||
|
.pipe(
|
||||||
|
switchMap(res => res.data),
|
||||||
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -281,6 +281,13 @@ export class Video implements VideoServerModel {
|
||||||
return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
|
return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) {
|
||||||
|
return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
|
||||||
|
user &&
|
||||||
|
this.isLocal === true &&
|
||||||
|
(this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
|
||||||
|
}
|
||||||
|
|
||||||
getExactNumberOfViews () {
|
getExactNumberOfViews () {
|
||||||
if (this.isLive) {
|
if (this.isLive) {
|
||||||
return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)
|
return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`)
|
||||||
|
|
|
@ -33,6 +33,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel'
|
||||||
import { VideoDetails } from './video-details.model'
|
import { VideoDetails } from './video-details.model'
|
||||||
import { VideoEdit } from './video-edit.model'
|
import { VideoEdit } from './video-edit.model'
|
||||||
import { Video } from './video.model'
|
import { Video } from './video.model'
|
||||||
|
import { VideoPasswordService } from './video-password.service'
|
||||||
|
|
||||||
export type CommonVideoParams = {
|
export type CommonVideoParams = {
|
||||||
videoPagination?: ComponentPaginationLight
|
videoPagination?: ComponentPaginationLight
|
||||||
|
@ -69,16 +70,17 @@ export class VideoService {
|
||||||
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
|
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideo (options: { videoId: string }): Observable<VideoDetails> {
|
getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
|
||||||
return this.serverService.getServerLocale()
|
const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
|
||||||
.pipe(
|
|
||||||
switchMap(translations => {
|
return this.serverService.getServerLocale().pipe(
|
||||||
return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`)
|
switchMap(translations => {
|
||||||
.pipe(map(videoHash => ({ videoHash, translations })))
|
return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers })
|
||||||
}),
|
.pipe(map(videoHash => ({ videoHash, translations })))
|
||||||
map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
|
}),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
|
||||||
)
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVideo (video: VideoEdit) {
|
updateVideo (video: VideoEdit) {
|
||||||
|
@ -99,6 +101,9 @@ export class VideoService {
|
||||||
description,
|
description,
|
||||||
channelId: video.channelId,
|
channelId: video.channelId,
|
||||||
privacy: video.privacy,
|
privacy: video.privacy,
|
||||||
|
videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
? [ video.videoPassword ]
|
||||||
|
: undefined,
|
||||||
tags: video.tags,
|
tags: video.tags,
|
||||||
nsfw: video.nsfw,
|
nsfw: video.nsfw,
|
||||||
waitTranscoding: video.waitTranscoding,
|
waitTranscoding: video.waitTranscoding,
|
||||||
|
@ -353,16 +358,16 @@ export class VideoService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
setVideoLike (id: string) {
|
setVideoLike (id: string, videoPassword: string) {
|
||||||
return this.setVideoRate(id, 'like')
|
return this.setVideoRate(id, 'like', videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
setVideoDislike (id: string) {
|
setVideoDislike (id: string, videoPassword: string) {
|
||||||
return this.setVideoRate(id, 'dislike')
|
return this.setVideoRate(id, 'dislike', videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
unsetVideoLike (id: string) {
|
unsetVideoLike (id: string, videoPassword: string) {
|
||||||
return this.setVideoRate(id, 'none')
|
return this.setVideoRate(id, 'none', videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserVideoRating (id: string) {
|
getUserVideoRating (id: string) {
|
||||||
|
@ -394,7 +399,8 @@ export class VideoService {
|
||||||
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
|
[VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
|
||||||
[VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
|
[VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
|
||||||
[VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
|
[VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
|
||||||
[VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`
|
[VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`,
|
||||||
|
[VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPrivacies = serverPrivacies.map(p => {
|
const videoPrivacies = serverPrivacies.map(p => {
|
||||||
|
@ -412,7 +418,13 @@ export class VideoService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
|
getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
|
||||||
const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ]
|
// We do not add a password as this requires additional configuration.
|
||||||
|
const order = [
|
||||||
|
VideoPrivacy.PRIVATE,
|
||||||
|
VideoPrivacy.INTERNAL,
|
||||||
|
VideoPrivacy.UNLISTED,
|
||||||
|
VideoPrivacy.PUBLIC
|
||||||
|
]
|
||||||
|
|
||||||
for (const privacy of order) {
|
for (const privacy of order) {
|
||||||
if (serverPrivacies.find(p => p.id === privacy)) {
|
if (serverPrivacies.find(p => p.id === privacy)) {
|
||||||
|
@ -499,14 +511,15 @@ export class VideoService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setVideoRate (id: string, rateType: UserVideoRateType) {
|
private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
|
||||||
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
|
const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
|
||||||
const body: UserVideoRateUpdate = {
|
const body: UserVideoRateUpdate = {
|
||||||
rating: rateType
|
rating: rateType
|
||||||
}
|
}
|
||||||
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.put(url, body)
|
.put(url, body, { headers })
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,6 +107,10 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning">
|
||||||
|
This video is password protected, please note that recipients will require the corresponding password to access the content.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
|
<div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
|
||||||
|
|
||||||
<ng-container ngbNavItem="url">
|
<ng-container ngbNavItem="url">
|
||||||
|
|
|
@ -243,6 +243,10 @@ export class VideoShareComponent {
|
||||||
return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
|
return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPasswordProtectedVideo () {
|
||||||
|
return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
}
|
||||||
|
|
||||||
private getPlaylistOptions (baseUrl?: string) {
|
private getPlaylistOptions (baseUrl?: string) {
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { environment } from '../../../environments/environment'
|
import { environment } from '../../../environments/environment'
|
||||||
import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
|
import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
|
||||||
import { VideoComment } from './video-comment.model'
|
import { VideoComment } from './video-comment.model'
|
||||||
|
import { VideoPasswordService } from '../shared-main'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoCommentService {
|
export class VideoCommentService {
|
||||||
|
@ -31,22 +32,25 @@ export class VideoCommentService {
|
||||||
private restService: RestService
|
private restService: RestService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
addCommentThread (videoId: string, comment: VideoCommentCreate) {
|
addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) {
|
||||||
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
|
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
|
||||||
const normalizedComment = objectLineFeedToHtml(comment, 'text')
|
const normalizedComment = objectLineFeedToHtml(comment, 'text')
|
||||||
|
|
||||||
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
|
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(data => this.extractVideoComment(data.comment)),
|
map(data => this.extractVideoComment(data.comment)),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) {
|
addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) {
|
||||||
|
const { videoId, inReplyToCommentId, comment, videoPassword } = options
|
||||||
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
|
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
|
||||||
const normalizedComment = objectLineFeedToHtml(comment, 'text')
|
const normalizedComment = objectLineFeedToHtml(comment, 'text')
|
||||||
|
|
||||||
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
|
return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(data => this.extractVideoComment(data.comment)),
|
map(data => this.extractVideoComment(data.comment)),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
@ -76,10 +80,13 @@ export class VideoCommentService {
|
||||||
|
|
||||||
getVideoCommentThreads (parameters: {
|
getVideoCommentThreads (parameters: {
|
||||||
videoId: string
|
videoId: string
|
||||||
|
videoPassword: string
|
||||||
componentPagination: ComponentPaginationLight
|
componentPagination: ComponentPaginationLight
|
||||||
sort: string
|
sort: string
|
||||||
}): Observable<ThreadsResultList<VideoComment>> {
|
}): Observable<ThreadsResultList<VideoComment>> {
|
||||||
const { videoId, componentPagination, sort } = parameters
|
const { videoId, videoPassword, componentPagination, sort } = parameters
|
||||||
|
|
||||||
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
const pagination = this.restService.componentToRestPagination(componentPagination)
|
const pagination = this.restService.componentToRestPagination(componentPagination)
|
||||||
|
|
||||||
|
@ -87,7 +94,7 @@ export class VideoCommentService {
|
||||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
params = this.restService.addRestGetParams(params, pagination, sort)
|
||||||
|
|
||||||
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
|
const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
|
||||||
return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params })
|
return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params, headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(result => this.extractVideoComments(result)),
|
map(result => this.extractVideoComments(result)),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
@ -97,12 +104,14 @@ export class VideoCommentService {
|
||||||
getVideoThreadComments (parameters: {
|
getVideoThreadComments (parameters: {
|
||||||
videoId: string
|
videoId: string
|
||||||
threadId: number
|
threadId: number
|
||||||
|
videoPassword?: string
|
||||||
}): Observable<VideoCommentThreadTree> {
|
}): Observable<VideoCommentThreadTree> {
|
||||||
const { videoId, threadId } = parameters
|
const { videoId, threadId, videoPassword } = parameters
|
||||||
const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
|
const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
|
||||||
|
const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<VideoCommentThreadTreeServerModel>(url)
|
.get<VideoCommentThreadTreeServerModel>(url, { headers })
|
||||||
.pipe(
|
.pipe(
|
||||||
map(tree => this.extractVideoCommentTree(tree)),
|
map(tree => this.extractVideoCommentTree(tree)),
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { mapValues } from 'lodash-es'
|
import { mapValues } from 'lodash-es'
|
||||||
import { firstValueFrom } from 'rxjs'
|
import { firstValueFrom } from 'rxjs'
|
||||||
import { tap } from 'rxjs/operators'
|
import { tap } from 'rxjs/operators'
|
||||||
import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
|
import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
|
||||||
import { HooksService } from '@app/core'
|
import { HooksService } from '@app/core'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { videoRequiresAuth } from '@root-helpers/video'
|
import { videoRequiresFileToken } from '@root-helpers/video'
|
||||||
import { objectKeysTyped, pick } from '@shared/core-utils'
|
import { objectKeysTyped, pick } from '@shared/core-utils'
|
||||||
import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
|
import { VideoCaption, VideoFile } from '@shared/models'
|
||||||
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
|
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
|
||||||
|
|
||||||
type DownloadType = 'video' | 'subtitles'
|
type DownloadType = 'video' | 'subtitles'
|
||||||
|
@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
|
||||||
export class VideoDownloadComponent {
|
export class VideoDownloadComponent {
|
||||||
@ViewChild('modal', { static: true }) modal: ElementRef
|
@ViewChild('modal', { static: true }) modal: ElementRef
|
||||||
|
|
||||||
|
@Input() videoPassword: string
|
||||||
|
|
||||||
downloadType: 'direct' | 'torrent' = 'direct'
|
downloadType: 'direct' | 'torrent' = 'direct'
|
||||||
|
|
||||||
resolutionId: number | string = -1
|
resolutionId: number | string = -1
|
||||||
|
@ -89,8 +91,8 @@ export class VideoDownloadComponent {
|
||||||
this.subtitleLanguageId = this.videoCaptions[0].language.id
|
this.subtitleLanguageId = this.videoCaptions[0].language.id
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoRequiresAuth(this.video)) {
|
if (this.isConfidentialVideo()) {
|
||||||
this.videoFileTokenService.getVideoFileToken(this.video.uuid)
|
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
|
||||||
.subscribe(({ token }) => this.videoFileToken = token)
|
.subscribe(({ token }) => this.videoFileToken = token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,7 +203,8 @@ export class VideoDownloadComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
isConfidentialVideo () {
|
isConfidentialVideo () {
|
||||||
return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
|
return videoRequiresFileToken(this.video)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToType (type: DownloadType) {
|
switchToType (type: DownloadType) {
|
||||||
|
|
|
@ -125,7 +125,7 @@
|
||||||
<my-peertube-checkbox
|
<my-peertube-checkbox
|
||||||
formControlName="allVideos"
|
formControlName="allVideos"
|
||||||
inputName="allVideos"
|
inputName="allVideos"
|
||||||
i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
|
i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
|
||||||
></my-peertube-checkbox>
|
></my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
|
<ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
|
||||||
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
|
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
|
||||||
|
<ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
|
||||||
</my-video-thumbnail>
|
</my-video-thumbnail>
|
||||||
|
|
||||||
<div class="video-bottom">
|
<div class="video-bottom">
|
||||||
|
|
|
@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
|
||||||
return this.video.privacy.id === VideoPrivacy.PRIVATE
|
return this.video.privacy.id === VideoPrivacy.PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPasswordProtectedVideo () {
|
||||||
|
return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
}
|
||||||
|
|
||||||
getStateLabel (video: Video) {
|
getStateLabel (video: Video) {
|
||||||
if (!video.state) return ''
|
if (!video.state) return ''
|
||||||
|
|
||||||
|
|
|
@ -241,6 +241,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadVideos () {
|
reloadVideos () {
|
||||||
|
console.log('reload')
|
||||||
this.pagination.currentPage = 1
|
this.pagination.currentPage = 1
|
||||||
this.loadMoreVideos(true)
|
this.loadMoreVideos(true)
|
||||||
}
|
}
|
||||||
|
@ -420,7 +421,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
|
||||||
if (reset) this.videos = []
|
if (reset) this.videos = []
|
||||||
this.videos = this.videos.concat(data)
|
this.videos = this.videos.concat(data)
|
||||||
|
console.log('subscribe')
|
||||||
if (this.groupByDate) this.buildGroupedDateLabels()
|
if (this.groupByDate) this.buildGroupedDateLabels()
|
||||||
|
|
||||||
this.onDataSubject.next(data)
|
this.onDataSubject.next(data)
|
||||||
|
|
|
@ -21,7 +21,8 @@
|
||||||
[attr.title]="playlistElement.video.name"
|
[attr.title]="playlistElement.video.name"
|
||||||
>{{ playlistElement.video.name }}</a>
|
>{{ playlistElement.video.name }}</a>
|
||||||
|
|
||||||
<span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
|
<span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
|
||||||
|
<span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="video-miniature-created-at-views">
|
<span class="video-miniature-created-at-views">
|
||||||
|
|
|
@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
|
||||||
return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
|
return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoPasswordProtected () {
|
||||||
|
return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
}
|
||||||
|
|
||||||
isUnavailable (e: VideoPlaylistElement) {
|
isUnavailable (e: VideoPlaylistElement) {
|
||||||
return e.type === VideoPlaylistElementType.UNAVAILABLE
|
return e.type === VideoPlaylistElementType.UNAVAILABLE
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ export class HLSOptionsBuilder {
|
||||||
const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
|
const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
|
||||||
|
|
||||||
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
||||||
requiresAuth: commonOptions.requiresAuth,
|
requiresUserAuth: commonOptions.requiresUserAuth,
|
||||||
videoFileToken: commonOptions.videoFileToken,
|
videoFileToken: commonOptions.videoFileToken,
|
||||||
|
|
||||||
redundancyUrlManager,
|
redundancyUrlManager,
|
||||||
|
@ -88,17 +88,24 @@ export class HLSOptionsBuilder {
|
||||||
httpFailedSegmentTimeout: 1000,
|
httpFailedSegmentTimeout: 1000,
|
||||||
|
|
||||||
xhrSetup: (xhr, url) => {
|
xhrSetup: (xhr, url) => {
|
||||||
if (!this.options.common.requiresAuth) return
|
const { requiresUserAuth, requiresPassword } = this.options.common
|
||||||
|
|
||||||
|
if (!(requiresUserAuth || requiresPassword)) return
|
||||||
|
|
||||||
if (!isSameOrigin(this.options.common.serverUrl, url)) return
|
if (!isSameOrigin(this.options.common.serverUrl, url)) return
|
||||||
|
|
||||||
xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
|
if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword())
|
||||||
|
|
||||||
|
else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader())
|
||||||
},
|
},
|
||||||
|
|
||||||
segmentValidator: segmentValidatorFactory({
|
segmentValidator: segmentValidatorFactory({
|
||||||
segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
|
segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
|
||||||
authorizationHeader: this.options.common.authorizationHeader,
|
authorizationHeader: this.options.common.authorizationHeader,
|
||||||
requiresAuth: this.options.common.requiresAuth,
|
requiresUserAuth: this.options.common.requiresUserAuth,
|
||||||
serverUrl: this.options.common.serverUrl
|
serverUrl: this.options.common.serverUrl,
|
||||||
|
requiresPassword: this.options.common.requiresPassword,
|
||||||
|
videoPassword: this.options.common.videoPassword
|
||||||
}),
|
}),
|
||||||
|
|
||||||
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
|
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
|
||||||
|
|
|
@ -26,10 +26,10 @@ export class WebTorrentOptionsBuilder {
|
||||||
|
|
||||||
videoFileToken: commonOptions.videoFileToken,
|
videoFileToken: commonOptions.videoFileToken,
|
||||||
|
|
||||||
requiresAuth: commonOptions.requiresAuth,
|
requiresUserAuth: commonOptions.requiresUserAuth,
|
||||||
|
|
||||||
buildWebSeedUrls: file => {
|
buildWebSeedUrls: file => {
|
||||||
if (!commonOptions.requiresAuth) return []
|
if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return []
|
||||||
|
|
||||||
return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
|
return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,11 +13,20 @@ function segmentValidatorFactory (options: {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
segmentsSha256Url: string
|
segmentsSha256Url: string
|
||||||
authorizationHeader: () => string
|
authorizationHeader: () => string
|
||||||
requiresAuth: boolean
|
requiresUserAuth: boolean
|
||||||
|
requiresPassword: boolean
|
||||||
|
videoPassword: () => string
|
||||||
}) {
|
}) {
|
||||||
const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options
|
const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options
|
||||||
|
|
||||||
let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth })
|
let segmentsJSON = fetchSha256Segments({
|
||||||
|
serverUrl,
|
||||||
|
segmentsSha256Url,
|
||||||
|
authorizationHeader,
|
||||||
|
requiresUserAuth,
|
||||||
|
requiresPassword,
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
const regex = /bytes=(\d+)-(\d+)/
|
const regex = /bytes=(\d+)-(\d+)/
|
||||||
|
|
||||||
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
|
return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
|
||||||
|
@ -34,7 +43,14 @@ function segmentValidatorFactory (options: {
|
||||||
|
|
||||||
await wait(500)
|
await wait(500)
|
||||||
|
|
||||||
segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth })
|
segmentsJSON = fetchSha256Segments({
|
||||||
|
serverUrl,
|
||||||
|
segmentsSha256Url,
|
||||||
|
authorizationHeader,
|
||||||
|
requiresUserAuth,
|
||||||
|
requiresPassword,
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
await segmentValidator(segment, _method, _peerId, retry + 1)
|
await segmentValidator(segment, _method, _peerId, retry + 1)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -78,13 +94,17 @@ function fetchSha256Segments (options: {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
segmentsSha256Url: string
|
segmentsSha256Url: string
|
||||||
authorizationHeader: () => string
|
authorizationHeader: () => string
|
||||||
requiresAuth: boolean
|
requiresUserAuth: boolean
|
||||||
|
requiresPassword: boolean
|
||||||
|
videoPassword: () => string
|
||||||
}): Promise<SegmentsJSON> {
|
}): Promise<SegmentsJSON> {
|
||||||
const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options
|
const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options
|
||||||
|
|
||||||
const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url)
|
let headers: { [ id: string ]: string } = {}
|
||||||
? { Authorization: authorizationHeader() }
|
if (isSameOrigin(serverUrl, segmentsSha256Url)) {
|
||||||
: {}
|
if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() }
|
||||||
|
else if (requiresUserAuth) headers = { Authorization: authorizationHeader() }
|
||||||
|
}
|
||||||
|
|
||||||
return fetch(segmentsSha256Url, { headers })
|
return fetch(segmentsSha256Url, { headers })
|
||||||
.then(res => res.json() as Promise<SegmentsJSON>)
|
.then(res => res.json() as Promise<SegmentsJSON>)
|
||||||
|
|
|
@ -59,7 +59,7 @@ class WebTorrentPlugin extends Plugin {
|
||||||
private isAutoResolutionObservation = false
|
private isAutoResolutionObservation = false
|
||||||
private playerRefusedP2P = false
|
private playerRefusedP2P = false
|
||||||
|
|
||||||
private requiresAuth: boolean
|
private requiresUserAuth: boolean
|
||||||
private videoFileToken: () => string
|
private videoFileToken: () => string
|
||||||
|
|
||||||
private torrentInfoInterval: any
|
private torrentInfoInterval: any
|
||||||
|
@ -86,7 +86,7 @@ class WebTorrentPlugin extends Plugin {
|
||||||
this.savePlayerSrcFunction = this.player.src
|
this.savePlayerSrcFunction = this.player.src
|
||||||
this.playerElement = options.playerElement
|
this.playerElement = options.playerElement
|
||||||
|
|
||||||
this.requiresAuth = options.requiresAuth
|
this.requiresUserAuth = options.requiresUserAuth
|
||||||
this.videoFileToken = options.videoFileToken
|
this.videoFileToken = options.videoFileToken
|
||||||
|
|
||||||
this.buildWebSeedUrls = options.buildWebSeedUrls
|
this.buildWebSeedUrls = options.buildWebSeedUrls
|
||||||
|
@ -546,7 +546,7 @@ class WebTorrentPlugin extends Plugin {
|
||||||
|
|
||||||
let httpUrl = this.currentVideoFile.fileUrl
|
let httpUrl = this.currentVideoFile.fileUrl
|
||||||
|
|
||||||
if (this.requiresAuth && this.videoFileToken) {
|
if (this.videoFileToken) {
|
||||||
httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
|
httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -83,8 +83,10 @@ export interface CommonOptions extends CustomizationOptions {
|
||||||
videoShortUUID: string
|
videoShortUUID: string
|
||||||
|
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
requiresAuth: boolean
|
requiresUserAuth: boolean
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
|
requiresPassword: boolean
|
||||||
|
videoPassword: () => string
|
||||||
|
|
||||||
errorNotifier: (message: string) => void
|
errorNotifier: (message: string) => void
|
||||||
}
|
}
|
||||||
|
|
|
@ -155,7 +155,7 @@ type WebtorrentPluginOptions = {
|
||||||
|
|
||||||
playerRefusedP2P: boolean
|
playerRefusedP2P: boolean
|
||||||
|
|
||||||
requiresAuth: boolean
|
requiresUserAuth: boolean
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
|
|
||||||
buildWebSeedUrls: (file: VideoFile) => string[]
|
buildWebSeedUrls: (file: VideoFile) => string[]
|
||||||
|
@ -170,7 +170,7 @@ type P2PMediaLoaderPluginOptions = {
|
||||||
|
|
||||||
loader: P2PMediaLoader
|
loader: P2PMediaLoader
|
||||||
|
|
||||||
requiresAuth: boolean
|
requiresUserAuth: boolean
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b
|
||||||
return userP2PEnabled
|
return userP2PEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoRequiresAuth (video: Video) {
|
function videoRequiresUserAuth (video: Video, videoPassword?: string) {
|
||||||
return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id)
|
return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
|
||||||
|
(video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoRequiresFileToken (video: Video, videoPassword?: string) {
|
||||||
|
return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildVideoOrPlaylistEmbed,
|
buildVideoOrPlaylistEmbed,
|
||||||
isP2PEnabled,
|
isP2PEnabled,
|
||||||
videoRequiresAuth
|
videoRequiresUserAuth,
|
||||||
|
videoRequiresFileToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -41,6 +41,23 @@
|
||||||
<div id="error-content"></div>
|
<div id="error-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="video-password-block">
|
||||||
|
<!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
|
||||||
|
<h1 id="video-password-title"></h1>
|
||||||
|
|
||||||
|
<div id="video-password-content"></div>
|
||||||
|
|
||||||
|
<form id="video-password-form">
|
||||||
|
<input type="password" id="video-password-input" name="video-password" required>
|
||||||
|
<button type="submit" id="video-password-submit"> </button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="video-password-error"></div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="video-wrapper"></div>
|
<div id="video-wrapper"></div>
|
||||||
|
|
||||||
<div id="placeholder-preview"></div>
|
<div id="placeholder-preview"></div>
|
||||||
|
|
|
@ -24,7 +24,7 @@ html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #000;
|
background-color: #0f0f10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#video-wrapper {
|
#video-wrapper {
|
||||||
|
@ -42,8 +42,10 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#error-block {
|
#error-block,
|
||||||
|
#video-password-block {
|
||||||
display: none;
|
display: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
|
@ -86,6 +88,43 @@ body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#video-password-content {
|
||||||
|
@include margin(1rem, 0, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-password-input,
|
||||||
|
#video-password-submit {
|
||||||
|
line-height: 23px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 3px!important;
|
||||||
|
font-size: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-password-submit {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #f2690d;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-password-submit:hover {
|
||||||
|
background-color: #f47825;
|
||||||
|
}
|
||||||
|
#video-password-error {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 2rem;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-password-block svg {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 300px) {
|
@media screen and (max-width: 300px) {
|
||||||
#error-block {
|
#error-block {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
|
|
|
@ -3,10 +3,18 @@ import '../../assets/player/shared/dock/peertube-dock-component'
|
||||||
import '../../assets/player/shared/dock/peertube-dock-plugin'
|
import '../../assets/player/shared/dock/peertube-dock-plugin'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
|
import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
|
||||||
import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models'
|
import {
|
||||||
|
HTMLServerConfig,
|
||||||
|
ResultList,
|
||||||
|
ServerErrorCode,
|
||||||
|
VideoDetails,
|
||||||
|
VideoPlaylist,
|
||||||
|
VideoPlaylistElement,
|
||||||
|
VideoState
|
||||||
|
} from '../../../../shared/models'
|
||||||
import { PeertubePlayerManager } from '../../assets/player'
|
import { PeertubePlayerManager } from '../../assets/player'
|
||||||
import { TranslationsManager } from '../../assets/player/translations-manager'
|
import { TranslationsManager } from '../../assets/player/translations-manager'
|
||||||
import { getParamString, logger, videoRequiresAuth } from '../../root-helpers'
|
import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
|
||||||
import { PeerTubeEmbedApi } from './embed-api'
|
import { PeerTubeEmbedApi } from './embed-api'
|
||||||
import {
|
import {
|
||||||
AuthHTTP,
|
AuthHTTP,
|
||||||
|
@ -19,6 +27,7 @@ import {
|
||||||
VideoFetcher
|
VideoFetcher
|
||||||
} from './shared'
|
} from './shared'
|
||||||
import { PlayerHTML } from './shared/player-html'
|
import { PlayerHTML } from './shared/player-html'
|
||||||
|
import { PeerTubeServerError } from 'src/types'
|
||||||
|
|
||||||
export class PeerTubeEmbed {
|
export class PeerTubeEmbed {
|
||||||
player: videojs.Player
|
player: videojs.Player
|
||||||
|
@ -38,6 +47,8 @@ export class PeerTubeEmbed {
|
||||||
private readonly liveManager: LiveManager
|
private readonly liveManager: LiveManager
|
||||||
|
|
||||||
private playlistTracker: PlaylistTracker
|
private playlistTracker: PlaylistTracker
|
||||||
|
private videoPassword: string
|
||||||
|
private requiresPassword: boolean
|
||||||
|
|
||||||
constructor (videoWrapperId: string) {
|
constructor (videoWrapperId: string) {
|
||||||
logger.registerServerSending(window.location.origin)
|
logger.registerServerSending(window.location.origin)
|
||||||
|
@ -50,6 +61,7 @@ export class PeerTubeEmbed {
|
||||||
this.playerHTML = new PlayerHTML(videoWrapperId)
|
this.playerHTML = new PlayerHTML(videoWrapperId)
|
||||||
this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
|
this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
|
||||||
this.liveManager = new LiveManager(this.playerHTML)
|
this.liveManager = new LiveManager(this.playerHTML)
|
||||||
|
this.requiresPassword = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
|
this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
|
||||||
|
@ -176,11 +188,13 @@ export class PeerTubeEmbed {
|
||||||
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
|
const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid)
|
const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
|
||||||
|
|
||||||
return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay })
|
return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.playerHTML.displayError(err.message, await this.translationsPromise)
|
|
||||||
|
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
|
||||||
|
else this.playerHTML.displayError(err.message, await this.translationsPromise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -205,8 +219,8 @@ export class PeerTubeEmbed {
|
||||||
? await this.videoFetcher.loadLive(videoInfo)
|
? await this.videoFetcher.loadLive(videoInfo)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const videoFileToken = videoRequiresAuth(videoInfo)
|
const videoFileToken = videoRequiresFileToken(videoInfo)
|
||||||
? await this.videoFetcher.loadVideoToken(videoInfo)
|
? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return { live, video: videoInfo, videoFileToken }
|
return { live, video: videoInfo, videoFileToken }
|
||||||
|
@ -232,6 +246,8 @@ export class PeerTubeEmbed {
|
||||||
|
|
||||||
authorizationHeader: () => this.http.getHeaderTokenValue(),
|
authorizationHeader: () => this.http.getHeaderTokenValue(),
|
||||||
videoFileToken: () => videoFileToken,
|
videoFileToken: () => videoFileToken,
|
||||||
|
videoPassword: () => this.videoPassword,
|
||||||
|
requiresPassword: this.requiresPassword,
|
||||||
|
|
||||||
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
|
onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }),
|
||||||
|
|
||||||
|
@ -263,6 +279,7 @@ export class PeerTubeEmbed {
|
||||||
this.initializeApi()
|
this.initializeApi()
|
||||||
|
|
||||||
this.playerHTML.removePlaceholder()
|
this.playerHTML.removePlaceholder()
|
||||||
|
if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
|
||||||
|
|
||||||
if (this.isPlaylistEmbed()) {
|
if (this.isPlaylistEmbed()) {
|
||||||
await this.buildPlayerPlaylistUpnext()
|
await this.buildPlayerPlaylistUpnext()
|
||||||
|
@ -401,6 +418,21 @@ export class PeerTubeEmbed {
|
||||||
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
|
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handlePasswordError (err: PeerTubeServerError) {
|
||||||
|
let incorrectPassword: boolean = null
|
||||||
|
if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
|
||||||
|
else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true
|
||||||
|
|
||||||
|
if (incorrectPassword === null) return false
|
||||||
|
|
||||||
|
this.requiresPassword = true
|
||||||
|
this.videoPassword = await this.playerHTML.askVideoPassword({
|
||||||
|
incorrectPassword,
|
||||||
|
translations: await this.translationsPromise
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PeerTubeEmbed.main()
|
PeerTubeEmbed.main()
|
||||||
|
|
|
@ -18,10 +18,12 @@ export class AuthHTTP {
|
||||||
if (this.userOAuthTokens) this.setHeadersFromTokens()
|
if (this.userOAuthTokens) this.setHeadersFromTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) {
|
fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
|
||||||
const refreshFetchOptions = optionalAuth
|
let refreshFetchOptions: { headers?: Headers } = {}
|
||||||
? { headers: this.headers }
|
|
||||||
: {}
|
if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
|
||||||
|
|
||||||
|
if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }
|
||||||
|
|
||||||
return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
|
return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,58 @@ export class PlayerHTML {
|
||||||
this.wrapperElement.style.display = 'none'
|
this.wrapperElement.style.display = 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> {
|
||||||
|
const { incorrectPassword, translations } = options
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
|
||||||
|
this.removePlaceholder()
|
||||||
|
this.wrapperElement.style.display = 'none'
|
||||||
|
|
||||||
|
const translatedTitle = peertubeTranslate('This video is password protected', translations)
|
||||||
|
const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations)
|
||||||
|
|
||||||
|
document.title = translatedTitle
|
||||||
|
|
||||||
|
const videoPasswordBlock = document.getElementById('video-password-block')
|
||||||
|
videoPasswordBlock.style.display = 'flex'
|
||||||
|
|
||||||
|
const videoPasswordTitle = document.getElementById('video-password-title')
|
||||||
|
videoPasswordTitle.innerHTML = translatedTitle
|
||||||
|
|
||||||
|
const videoPasswordMessage = document.getElementById('video-password-content')
|
||||||
|
videoPasswordMessage.innerHTML = translatedMessage
|
||||||
|
|
||||||
|
if (incorrectPassword) {
|
||||||
|
const videoPasswordError = document.getElementById('video-password-error')
|
||||||
|
videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations)
|
||||||
|
videoPasswordError.style.transform = 'scale(1.2)'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
videoPasswordError.style.transform = 'scale(1)'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPasswordSubmitButton = document.getElementById('video-password-submit')
|
||||||
|
videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations)
|
||||||
|
|
||||||
|
const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement
|
||||||
|
videoPasswordInput.placeholder = peertubeTranslate('Password', translations)
|
||||||
|
|
||||||
|
const videoPasswordForm = document.getElementById('video-password-form')
|
||||||
|
videoPasswordForm.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const videoPassword = videoPasswordInput.value
|
||||||
|
resolve(videoPassword)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeVideoPasswordBlock () {
|
||||||
|
const videoPasswordBlock = document.getElementById('video-password-block')
|
||||||
|
videoPasswordBlock.style.display = 'none'
|
||||||
|
this.wrapperElement.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
buildPlaceholder (video: VideoDetails) {
|
buildPlaceholder (video: VideoDetails) {
|
||||||
const placeholder = this.getPlaceholderElement()
|
const placeholder = this.getPlaceholderElement()
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
logger,
|
logger,
|
||||||
peertubeLocalStorage,
|
peertubeLocalStorage,
|
||||||
UserLocalStorageKeys,
|
UserLocalStorageKeys,
|
||||||
videoRequiresAuth
|
videoRequiresUserAuth
|
||||||
} from '../../../root-helpers'
|
} from '../../../root-helpers'
|
||||||
import { PeerTubePlugin } from './peertube-plugin'
|
import { PeerTubePlugin } from './peertube-plugin'
|
||||||
import { PlayerHTML } from './player-html'
|
import { PlayerHTML } from './player-html'
|
||||||
|
@ -162,6 +162,9 @@ export class PlayerManagerOptions {
|
||||||
authorizationHeader: () => string
|
authorizationHeader: () => string
|
||||||
videoFileToken: () => string
|
videoFileToken: () => string
|
||||||
|
|
||||||
|
videoPassword: () => string
|
||||||
|
requiresPassword: boolean
|
||||||
|
|
||||||
serverConfig: HTMLServerConfig
|
serverConfig: HTMLServerConfig
|
||||||
|
|
||||||
autoplayFromPreviousVideo: boolean
|
autoplayFromPreviousVideo: boolean
|
||||||
|
@ -178,6 +181,8 @@ export class PlayerManagerOptions {
|
||||||
captionsResponse,
|
captionsResponse,
|
||||||
autoplayFromPreviousVideo,
|
autoplayFromPreviousVideo,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
|
videoPassword,
|
||||||
|
requiresPassword,
|
||||||
translations,
|
translations,
|
||||||
forceAutoplay,
|
forceAutoplay,
|
||||||
playlistTracker,
|
playlistTracker,
|
||||||
|
@ -242,10 +247,13 @@ export class PlayerManagerOptions {
|
||||||
embedUrl: window.location.origin + video.embedPath,
|
embedUrl: window.location.origin + video.embedPath,
|
||||||
embedTitle: video.name,
|
embedTitle: video.name,
|
||||||
|
|
||||||
requiresAuth: videoRequiresAuth(video),
|
requiresUserAuth: videoRequiresUserAuth(video),
|
||||||
authorizationHeader,
|
authorizationHeader,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
|
|
||||||
|
requiresPassword,
|
||||||
|
videoPassword,
|
||||||
|
|
||||||
errorNotifier: () => {
|
errorNotifier: () => {
|
||||||
// Empty, we don't have a notifier in the embed
|
// Empty, we don't have a notifier in the embed
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { PeerTubeServerError } from '../../../types'
|
||||||
import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
|
import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
|
||||||
import { logger } from '../../../root-helpers'
|
import { logger } from '../../../root-helpers'
|
||||||
import { AuthHTTP } from './auth-http'
|
import { AuthHTTP } from './auth-http'
|
||||||
|
@ -8,8 +9,8 @@ export class VideoFetcher {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadVideo (videoId: string) {
|
async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
|
||||||
const videoPromise = this.loadVideoInfo(videoId)
|
const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
|
||||||
|
|
||||||
let videoResponse: Response
|
let videoResponse: Response
|
||||||
let isResponseOk: boolean
|
let isResponseOk: boolean
|
||||||
|
@ -27,11 +28,14 @@ export class VideoFetcher {
|
||||||
if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
|
if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
|
||||||
throw new Error('This video does not exist.')
|
throw new Error('This video does not exist.')
|
||||||
}
|
}
|
||||||
|
if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) {
|
||||||
|
const res = await videoResponse.json()
|
||||||
|
throw new PeerTubeServerError(res.message, res.code)
|
||||||
|
}
|
||||||
throw new Error('We cannot fetch the video. Please try again later.')
|
throw new Error('We cannot fetch the video. Please try again later.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const captionsPromise = this.loadVideoCaptions(videoId)
|
const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
|
||||||
|
|
||||||
return { captionsPromise, videoResponse }
|
return { captionsPromise, videoResponse }
|
||||||
}
|
}
|
||||||
|
@ -41,8 +45,8 @@ export class VideoFetcher {
|
||||||
.then(res => res.json() as Promise<LiveVideo>)
|
.then(res => res.json() as Promise<LiveVideo>)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadVideoToken (video: VideoDetails) {
|
loadVideoToken (video: VideoDetails, videoPassword?: string) {
|
||||||
return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' })
|
return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
|
||||||
.then(res => res.json() as Promise<VideoToken>)
|
.then(res => res.json() as Promise<VideoToken>)
|
||||||
.then(token => token.files.token)
|
.then(token => token.files.token)
|
||||||
}
|
}
|
||||||
|
@ -51,12 +55,12 @@ export class VideoFetcher {
|
||||||
return this.getVideoUrl(videoUUID) + '/views'
|
return this.getVideoUrl(videoUUID) + '/views'
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadVideoInfo (videoId: string): Promise<Response> {
|
private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
|
||||||
return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
|
return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadVideoCaptions (videoId: string): Promise<Response> {
|
private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
|
||||||
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
|
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVideoUrl (id: string) {
|
private getVideoUrl (id: string) {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './client-script.model'
|
export * from './client-script.model'
|
||||||
|
export * from './server-error.model'
|
||||||
export * from './job-state-client.type'
|
export * from './job-state-client.type'
|
||||||
export * from './job-type-client.type'
|
export * from './job-type-client.type'
|
||||||
export * from './link.type'
|
export * from './link.type'
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ServerErrorCode } from '@shared/models/index'
|
||||||
|
|
||||||
|
export class PeerTubeServerError extends Error {
|
||||||
|
serverCode: ServerErrorCode
|
||||||
|
|
||||||
|
constructor (message: string, serverCode: ServerErrorCode) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'CustomError'
|
||||||
|
this.serverCode = serverCode
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,7 +69,10 @@ const playerKeys = {
|
||||||
'{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
|
'{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
|
||||||
'Previous video': 'Previous video',
|
'Previous video': 'Previous video',
|
||||||
'Video page (new window)': 'Video page (new window)',
|
'Video page (new window)': 'Video page (new window)',
|
||||||
'Next video': 'Next video'
|
'Next video': 'Next video',
|
||||||
|
'This video is password protected': 'This video is password protected',
|
||||||
|
'You need a password to watch this video.': 'You need a password to watch this video.',
|
||||||
|
'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password'
|
||||||
}
|
}
|
||||||
Object.assign(playerKeys, videojs)
|
Object.assign(playerKeys, videojs)
|
||||||
|
|
||||||
|
|
|
@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
|
||||||
videoChannel: res.locals.videoChannel,
|
videoChannel: res.locals.videoChannel,
|
||||||
tags: body.tags || undefined,
|
tags: body.tags || undefined,
|
||||||
user,
|
user,
|
||||||
|
videoPasswords: body.videoPasswords,
|
||||||
videoImportAttributes: {
|
videoImportAttributes: {
|
||||||
magnetUri,
|
magnetUri,
|
||||||
torrentName,
|
torrentName,
|
||||||
|
|
|
@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding'
|
||||||
import { updateRouter } from './update'
|
import { updateRouter } from './update'
|
||||||
import { uploadRouter } from './upload'
|
import { uploadRouter } from './upload'
|
||||||
import { viewRouter } from './view'
|
import { viewRouter } from './view'
|
||||||
|
import { videoPasswordRouter } from './passwords'
|
||||||
|
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
const videosRouter = express.Router()
|
const videosRouter = express.Router()
|
||||||
|
@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter)
|
||||||
videosRouter.use('/', filesRouter)
|
videosRouter.use('/', filesRouter)
|
||||||
videosRouter.use('/', transcodingRouter)
|
videosRouter.use('/', transcodingRouter)
|
||||||
videosRouter.use('/', tokenRouter)
|
videosRouter.use('/', tokenRouter)
|
||||||
|
videosRouter.use('/', videoPasswordRouter)
|
||||||
|
|
||||||
videosRouter.get('/categories',
|
videosRouter.get('/categories',
|
||||||
openapiOperationDoc({ operationId: 'getCategories' }),
|
openapiOperationDoc({ operationId: 'getCategories' }),
|
||||||
|
|
|
@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live'
|
||||||
import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
|
import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
|
||||||
import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
|
import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
|
||||||
import { buildUUID, uuidToShort } from '@shared/extra-utils'
|
import { buildUUID, uuidToShort } from '@shared/extra-utils'
|
||||||
import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models'
|
import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { sequelizeTypescript } from '../../../initializers/database'
|
import { sequelizeTypescript } from '../../../initializers/database'
|
||||||
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
|
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
|
||||||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
|
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
|
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
|
||||||
const liveRouter = express.Router()
|
const liveRouter = express.Router()
|
||||||
|
|
||||||
|
@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
await federateVideoIfNeeded(videoCreated, true, t)
|
await federateVideoIfNeeded(videoCreated, true, t)
|
||||||
|
|
||||||
|
if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
|
logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
|
||||||
|
|
||||||
return { videoCreated }
|
return { videoCreated }
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
import express from 'express'
|
||||||
|
|
||||||
|
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
|
||||||
|
import { getFormattedObjects } from '../../../helpers/utils'
|
||||||
|
import {
|
||||||
|
asyncMiddleware,
|
||||||
|
asyncRetryTransactionMiddleware,
|
||||||
|
authenticate,
|
||||||
|
setDefaultPagination,
|
||||||
|
setDefaultSort
|
||||||
|
} from '../../../middlewares'
|
||||||
|
import {
|
||||||
|
listVideoPasswordValidator,
|
||||||
|
paginationValidator,
|
||||||
|
removeVideoPasswordValidator,
|
||||||
|
updateVideoPasswordListValidator,
|
||||||
|
videoPasswordsSortValidator
|
||||||
|
} from '../../../middlewares/validators'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
|
import { getVideoWithAttributes } from '@server/helpers/video'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
|
const videoPasswordRouter = express.Router()
|
||||||
|
|
||||||
|
videoPasswordRouter.get('/:videoId/passwords',
|
||||||
|
authenticate,
|
||||||
|
paginationValidator,
|
||||||
|
videoPasswordsSortValidator,
|
||||||
|
setDefaultSort,
|
||||||
|
setDefaultPagination,
|
||||||
|
asyncMiddleware(listVideoPasswordValidator),
|
||||||
|
asyncMiddleware(listVideoPasswords)
|
||||||
|
)
|
||||||
|
|
||||||
|
videoPasswordRouter.put('/:videoId/passwords',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(updateVideoPasswordListValidator),
|
||||||
|
asyncMiddleware(updateVideoPasswordList)
|
||||||
|
)
|
||||||
|
|
||||||
|
videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(removeVideoPasswordValidator),
|
||||||
|
asyncRetryTransactionMiddleware(removeVideoPassword)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
videoPasswordRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function listVideoPasswords (req: express.Request, res: express.Response) {
|
||||||
|
const options = {
|
||||||
|
videoId: res.locals.videoAll.id,
|
||||||
|
start: req.query.start,
|
||||||
|
count: req.query.count,
|
||||||
|
sort: req.query.sort
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultList = await VideoPasswordModel.listPasswords(options)
|
||||||
|
|
||||||
|
return res.json(getFormattedObjects(resultList.data, resultList.total))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateVideoPasswordList (req: express.Request, res: express.Response) {
|
||||||
|
const videoInstance = getVideoWithAttributes(res)
|
||||||
|
const videoId = videoInstance.id
|
||||||
|
|
||||||
|
const passwordArray = req.body.passwords as string[]
|
||||||
|
|
||||||
|
await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
|
||||||
|
await VideoPasswordModel.deleteAllPasswords(videoId, t)
|
||||||
|
await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Video passwords for video with name %s and uuid %s have been updated`,
|
||||||
|
videoInstance.name,
|
||||||
|
videoInstance.uuid,
|
||||||
|
lTags(videoInstance.uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeVideoPassword (req: express.Request, res: express.Response) {
|
||||||
|
const videoInstance = getVideoWithAttributes(res)
|
||||||
|
const password = res.locals.videoPassword
|
||||||
|
|
||||||
|
await VideoPasswordModel.deletePassword(password.id)
|
||||||
|
logger.info(
|
||||||
|
'Password with id %d of video named %s and uuid %s has been deleted.',
|
||||||
|
password.id,
|
||||||
|
videoInstance.name,
|
||||||
|
videoInstance.uuid,
|
||||||
|
lTags(videoInstance.uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
|
||||||
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { VideoTokensManager } from '@server/lib/video-tokens-manager'
|
import { VideoTokensManager } from '@server/lib/video-tokens-manager'
|
||||||
import { VideoToken } from '@shared/models'
|
import { VideoPrivacy, VideoToken } from '@shared/models'
|
||||||
import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares'
|
import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
|
||||||
|
|
||||||
const tokenRouter = express.Router()
|
const tokenRouter = express.Router()
|
||||||
|
|
||||||
tokenRouter.post('/:id/token',
|
tokenRouter.post('/:id/token',
|
||||||
authenticate,
|
optionalAuthenticate,
|
||||||
asyncMiddleware(videosCustomGetValidator('only-video')),
|
asyncMiddleware(videosCustomGetValidator('only-video')),
|
||||||
|
videoFileTokenValidator,
|
||||||
generateToken
|
generateToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -22,12 +23,11 @@ export {
|
||||||
function generateToken (req: express.Request, res: express.Response) {
|
function generateToken (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.onlyVideo
|
const video = res.locals.onlyVideo
|
||||||
|
|
||||||
const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
|
const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
|
||||||
|
: VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
files: {
|
files
|
||||||
token,
|
|
||||||
expires
|
|
||||||
}
|
|
||||||
} as VideoToken)
|
} as VideoToken)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,12 @@ import express from 'express'
|
||||||
import { Transaction } from 'sequelize/types'
|
import { Transaction } from 'sequelize/types'
|
||||||
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
|
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
|
||||||
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
|
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
|
||||||
import { setVideoPrivacy } from '@server/lib/video-privacy'
|
import { setVideoPrivacy } from '@server/lib/video-privacy'
|
||||||
import { openapiOperationDoc } from '@server/middlewares/doc'
|
import { openapiOperationDoc } from '@server/middlewares/doc'
|
||||||
import { FilteredModelAttributes } from '@server/types'
|
import { FilteredModelAttributes } from '@server/types'
|
||||||
import { MVideoFullLight } from '@server/types/models'
|
import { MVideoFullLight } from '@server/types/models'
|
||||||
import { forceNumber } from '@shared/core-utils'
|
import { forceNumber } from '@shared/core-utils'
|
||||||
import { HttpStatusCode, VideoUpdate } from '@shared/models'
|
import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
|
||||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||||
import { resetSequelizeInstance } from '../../../helpers/database-utils'
|
import { resetSequelizeInstance } from '../../../helpers/database-utils'
|
||||||
import { createReqFiles } from '../../../helpers/express-utils'
|
import { createReqFiles } from '../../../helpers/express-utils'
|
||||||
|
@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
|
||||||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
|
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
import { exists } from '@server/helpers/custom-validators/misc'
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'video')
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
|
@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
|
||||||
const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
|
const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
|
||||||
setVideoPrivacy(videoInstance, newPrivacy)
|
setVideoPrivacy(videoInstance, newPrivacy)
|
||||||
|
|
||||||
|
// Delete passwords if video is not anymore password protected
|
||||||
|
if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
|
||||||
|
await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
|
||||||
|
await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
|
||||||
|
}
|
||||||
|
|
||||||
// Unfederate the video if the new privacy is not compatible with federation
|
// Unfederate the video if the new privacy is not compatible with federation
|
||||||
if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
|
if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
|
||||||
await VideoModel.sendDelete(videoInstance, { transaction })
|
await VideoModel.sendDelete(videoInstance, { transaction })
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
|
||||||
import { VideoSourceModel } from '@server/models/video/video-source'
|
import { VideoSourceModel } from '@server/models/video/video-source'
|
||||||
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
|
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
import { uuidToShort } from '@shared/extra-utils'
|
import { uuidToShort } from '@shared/extra-utils'
|
||||||
import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models'
|
import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
|
||||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||||
import { createReqFiles } from '../../../helpers/express-utils'
|
import { createReqFiles } from '../../../helpers/express-utils'
|
||||||
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
|
@ -33,6 +33,7 @@ import {
|
||||||
} from '../../../middlewares'
|
} from '../../../middlewares'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'video')
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
|
@ -195,6 +196,10 @@ async function addVideo (options: {
|
||||||
transaction: t
|
transaction: t
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
|
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
|
||||||
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
|
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { UploadFilesForCheck } from 'express'
|
import { Response, Request, UploadFilesForCheck } from 'express'
|
||||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
|
import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
CONSTRAINTS_FIELDS,
|
CONSTRAINTS_FIELDS,
|
||||||
MIMETYPES,
|
MIMETYPES,
|
||||||
|
@ -13,6 +13,7 @@ import {
|
||||||
VIDEO_STATES
|
VIDEO_STATES
|
||||||
} from '../../initializers/constants'
|
} from '../../initializers/constants'
|
||||||
import { exists, isArray, isDateValid, isFileValid } from './misc'
|
import { exists, isArray, isDateValid, isFileValid } from './misc'
|
||||||
|
import { getVideoWithAttributes } from '@server/helpers/video'
|
||||||
|
|
||||||
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
|
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
|
||||||
|
|
||||||
|
@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
|
||||||
return VIDEO_PRIVACIES[value] !== undefined
|
return VIDEO_PRIVACIES[value] !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideoReplayPrivacyValid (value: number) {
|
||||||
|
return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
}
|
||||||
|
|
||||||
function isScheduleVideoUpdatePrivacyValid (value: number) {
|
function isScheduleVideoUpdatePrivacyValid (value: number) {
|
||||||
return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
|
return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
|
||||||
}
|
}
|
||||||
|
@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
|
||||||
return parsed && isVideoFileInfoHashValid(parsed.infoHash)
|
return parsed && isVideoFileInfoHashValid(parsed.infoHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPasswordValid (password: string) {
|
||||||
|
return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min &&
|
||||||
|
password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPasswordProtectedPrivacy (req: Request, res: Response) {
|
||||||
|
const fail = (message: string) => {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let privacy: VideoPrivacy
|
||||||
|
const video = getVideoWithAttributes(res)
|
||||||
|
|
||||||
|
if (exists(req.body?.privacy)) privacy = req.body.privacy
|
||||||
|
else if (exists(video?.privacy)) privacy = video.privacy
|
||||||
|
|
||||||
|
if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true
|
||||||
|
|
||||||
|
if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.')
|
||||||
|
|
||||||
|
const passwords = req.body.videoPasswords || req.body.passwords
|
||||||
|
|
||||||
|
if (passwords.length === 0) return fail('At least one video password is required.')
|
||||||
|
|
||||||
|
if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.')
|
||||||
|
|
||||||
|
for (const password of passwords) {
|
||||||
|
if (typeof password !== 'string') {
|
||||||
|
return fail('Video password should be a string.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid(password)) {
|
||||||
|
return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -164,9 +212,12 @@ export {
|
||||||
isVideoDurationValid,
|
isVideoDurationValid,
|
||||||
isVideoTagValid,
|
isVideoTagValid,
|
||||||
isVideoPrivacyValid,
|
isVideoPrivacyValid,
|
||||||
|
isVideoReplayPrivacyValid,
|
||||||
isVideoFileResolutionValid,
|
isVideoFileResolutionValid,
|
||||||
isVideoFileSizeValid,
|
isVideoFileSizeValid,
|
||||||
isVideoImageValid,
|
isVideoImageValid,
|
||||||
isVideoSupportValid,
|
isVideoSupportValid,
|
||||||
isVideoFilterValid
|
isVideoFilterValid,
|
||||||
|
isPasswordValid,
|
||||||
|
isValidPasswordProtectedPrivacy
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const LAST_MIGRATION_VERSION = 780
|
const LAST_MIGRATION_VERSION = 785
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = {
|
||||||
VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
|
VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
|
||||||
VIDEO_COMMENTS: [ 'createdAt' ],
|
VIDEO_COMMENTS: [ 'createdAt' ],
|
||||||
|
|
||||||
|
VIDEO_PASSWORDS: [ 'createdAt' ],
|
||||||
|
|
||||||
VIDEO_RATES: [ 'createdAt' ],
|
VIDEO_RATES: [ 'createdAt' ],
|
||||||
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
|
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
|
||||||
|
|
||||||
|
@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = {
|
||||||
REASON: { min: 1, max: 5000 }, // Length
|
REASON: { min: 1, max: 5000 }, // Length
|
||||||
ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
|
ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
|
||||||
PROGRESS: { min: 0, max: 100 } // Value
|
PROGRESS: { min: 0, max: 100 } // Value
|
||||||
|
},
|
||||||
|
VIDEO_PASSWORD: {
|
||||||
|
LENGTH: { min: 2, max: 100 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
|
||||||
[VideoPrivacy.PUBLIC]: 'Public',
|
[VideoPrivacy.PUBLIC]: 'Public',
|
||||||
[VideoPrivacy.UNLISTED]: 'Unlisted',
|
[VideoPrivacy.UNLISTED]: 'Unlisted',
|
||||||
[VideoPrivacy.PRIVATE]: 'Private',
|
[VideoPrivacy.PRIVATE]: 'Private',
|
||||||
[VideoPrivacy.INTERNAL]: 'Internal'
|
[VideoPrivacy.INTERNAL]: 'Internal',
|
||||||
|
[VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_STATES: { [ id in VideoState ]: string } = {
|
const VIDEO_STATES: { [ id in VideoState ]: string } = {
|
||||||
|
|
|
@ -56,6 +56,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
|
||||||
import { VideoTagModel } from '../models/video/video-tag'
|
import { VideoTagModel } from '../models/video/video-tag'
|
||||||
import { VideoViewModel } from '../models/view/video-view'
|
import { VideoViewModel } from '../models/view/video-view'
|
||||||
import { CONFIG } from './config'
|
import { CONFIG } from './config'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
|
||||||
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
||||||
|
|
||||||
|
@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) {
|
||||||
VideoJobInfoModel,
|
VideoJobInfoModel,
|
||||||
VideoChannelSyncModel,
|
VideoChannelSyncModel,
|
||||||
UserRegistrationModel,
|
UserRegistrationModel,
|
||||||
|
VideoPasswordModel,
|
||||||
RunnerRegistrationTokenModel,
|
RunnerRegistrationTokenModel,
|
||||||
RunnerModel,
|
RunnerModel,
|
||||||
RunnerJobModel
|
RunnerJobModel
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
{
|
||||||
|
const query = `
|
||||||
|
CREATE TABLE IF NOT EXISTS "videoPassword" (
|
||||||
|
"id" SERIAL,
|
||||||
|
"password" VARCHAR(255) NOT NULL,
|
||||||
|
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
await utils.sequelize.query(query, { transaction : utils.transaction })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity'
|
||||||
import { getBiggestActorImage } from './actor-image'
|
import { getBiggestActorImage } from './actor-image'
|
||||||
import { Hooks } from './plugins/hooks'
|
import { Hooks } from './plugins/hooks'
|
||||||
import { ServerConfigManager } from './server-config-manager'
|
import { ServerConfigManager } from './server-config-manager'
|
||||||
|
import { isVideoInPrivateDirectory } from './video-privacy'
|
||||||
|
|
||||||
type Tags = {
|
type Tags = {
|
||||||
ogType: string
|
ogType: string
|
||||||
|
@ -106,7 +107,7 @@ class ClientHtml {
|
||||||
])
|
])
|
||||||
|
|
||||||
// Let Angular application handle errors
|
// Let Angular application handle errors
|
||||||
if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) {
|
if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
|
||||||
res.status(HttpStatusCode.NOT_FOUND_404)
|
res.status(HttpStatusCode.NOT_FOUND_404)
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
|
import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
|
||||||
import { getLocalVideoActivityPubUrl } from './activitypub/url'
|
import { getLocalVideoActivityPubUrl } from './activitypub/url'
|
||||||
import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
|
import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
|
||||||
class YoutubeDlImportError extends Error {
|
class YoutubeDlImportError extends Error {
|
||||||
code: YoutubeDlImportError.CODE
|
code: YoutubeDlImportError.CODE
|
||||||
|
@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
|
||||||
tags: string[]
|
tags: string[]
|
||||||
videoImportAttributes: FilteredModelAttributes<VideoImportModel>
|
videoImportAttributes: FilteredModelAttributes<VideoImportModel>
|
||||||
user: MUser
|
user: MUser
|
||||||
|
videoPasswords?: string[]
|
||||||
}): Promise<MVideoImportFormattable> {
|
}): Promise<MVideoImportFormattable> {
|
||||||
const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters
|
const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
|
||||||
|
|
||||||
const videoImport = await sequelizeTypescript.transaction(async t => {
|
const videoImport = await sequelizeTypescript.transaction(async t => {
|
||||||
const sequelizeOptions = { transaction: t }
|
const sequelizeOptions = { transaction: t }
|
||||||
|
@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
|
||||||
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
|
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
|
||||||
if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
|
if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
|
||||||
|
|
||||||
|
if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
|
||||||
|
}
|
||||||
|
|
||||||
await autoBlacklistVideoIfNeeded({
|
await autoBlacklistVideoIfNeeded({
|
||||||
video: videoCreated,
|
video: videoCreated,
|
||||||
user,
|
user,
|
||||||
|
@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
|
||||||
state: VideoImportState.PENDING,
|
state: VideoImportState.PENDING,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
videoChannelSyncId: channelSync?.id
|
videoChannelSyncId: channelSync?.id
|
||||||
}
|
},
|
||||||
|
videoPasswords: importDataOverride.videoPasswords
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get video subtitles
|
// Get video subtitles
|
||||||
|
|
|
@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
import { VideoPrivacy, VideoStorage } from '@shared/models'
|
import { VideoPrivacy, VideoStorage } from '@shared/models'
|
||||||
import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
|
import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
|
||||||
|
|
||||||
|
const validPrivacySet = new Set([
|
||||||
|
VideoPrivacy.PRIVATE,
|
||||||
|
VideoPrivacy.INTERNAL,
|
||||||
|
VideoPrivacy.PASSWORD_PROTECTED
|
||||||
|
])
|
||||||
|
|
||||||
function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
|
function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
|
||||||
if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
|
if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
|
||||||
video.publishedAt = new Date()
|
video.publishedAt = new Date()
|
||||||
|
@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
|
||||||
video.privacy = newPrivacy
|
video.privacy = newPrivacy
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoInPrivateDirectory (privacy: VideoPrivacy) {
|
function isVideoInPrivateDirectory (privacy) {
|
||||||
return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL
|
return validPrivacySet.has(privacy)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoInPublicDirectory (privacy: VideoPrivacy) {
|
function isVideoInPublicDirectory (privacy: VideoPrivacy) {
|
||||||
|
|
|
@ -12,26 +12,34 @@ class VideoTokensManager {
|
||||||
|
|
||||||
private static instance: VideoTokensManager
|
private static instance: VideoTokensManager
|
||||||
|
|
||||||
private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
|
private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
|
||||||
max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
|
max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
|
||||||
ttl: LRU_CACHE.VIDEO_TOKENS.TTL
|
ttl: LRU_CACHE.VIDEO_TOKENS.TTL
|
||||||
})
|
})
|
||||||
|
|
||||||
private constructor () {}
|
private constructor () {}
|
||||||
|
|
||||||
create (options: {
|
createForAuthUser (options: {
|
||||||
user: MUserAccountUrl
|
user: MUserAccountUrl
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
}) {
|
}) {
|
||||||
const token = buildUUID()
|
const { token, expires } = this.generateVideoToken()
|
||||||
|
|
||||||
const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
|
|
||||||
|
|
||||||
this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
|
this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
|
||||||
|
|
||||||
return { token, expires }
|
return { token, expires }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createForPasswordProtectedVideo (options: {
|
||||||
|
videoUUID: string
|
||||||
|
}) {
|
||||||
|
const { token, expires } = this.generateVideoToken()
|
||||||
|
|
||||||
|
this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
|
||||||
|
|
||||||
|
return { token, expires }
|
||||||
|
}
|
||||||
|
|
||||||
hasToken (options: {
|
hasToken (options: {
|
||||||
token: string
|
token: string
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
|
@ -54,6 +62,13 @@ class VideoTokensManager {
|
||||||
static get Instance () {
|
static get Instance () {
|
||||||
return this.instance || (this.instance = new this())
|
return this.instance || (this.instance = new this())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateVideoToken () {
|
||||||
|
const token = buildUUID()
|
||||||
|
const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
|
||||||
|
|
||||||
|
return { token, expires }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner'
|
||||||
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
||||||
import { logger } from '../helpers/logger'
|
import { logger } from '../helpers/logger'
|
||||||
import { handleOAuthAuthenticate } from '../lib/auth/oauth'
|
import { handleOAuthAuthenticate } from '../lib/auth/oauth'
|
||||||
|
import { ServerErrorCode } from '@shared/models'
|
||||||
|
|
||||||
function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
handleOAuthAuthenticate(req, res)
|
handleOAuthAuthenticate(req, res)
|
||||||
|
@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
|
||||||
.catch(err => logger.error('Cannot get access token.', { err }))
|
.catch(err => logger.error('Cannot get access token.', { err }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function authenticatePromise (req: express.Request, res: express.Response) {
|
function authenticatePromise (options: {
|
||||||
|
req: express.Request
|
||||||
|
res: express.Response
|
||||||
|
errorMessage?: string
|
||||||
|
errorStatus?: HttpStatusCode
|
||||||
|
errorType?: ServerErrorCode
|
||||||
|
}) {
|
||||||
|
const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
|
||||||
return new Promise<void>(resolve => {
|
return new Promise<void>(resolve => {
|
||||||
// Already authenticated? (or tried to)
|
// Already authenticated? (or tried to)
|
||||||
if (res.locals.oauth?.token.User) return resolve()
|
if (res.locals.oauth?.token.User) return resolve()
|
||||||
|
|
||||||
if (res.locals.authenticated === false) {
|
if (res.locals.authenticated === false) {
|
||||||
return res.fail({
|
return res.fail({
|
||||||
status: HttpStatusCode.UNAUTHORIZED_401,
|
status: errorStatus,
|
||||||
message: 'Not authenticated'
|
type: errorType,
|
||||||
|
message: errorMessage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,4 +10,5 @@ export * from './video-comments'
|
||||||
export * from './video-imports'
|
export * from './video-imports'
|
||||||
export * from './video-ownerships'
|
export * from './video-ownerships'
|
||||||
export * from './video-playlists'
|
export * from './video-playlists'
|
||||||
|
export * from './video-passwords'
|
||||||
export * from './videos'
|
export * from './videos'
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models'
|
||||||
|
import { forceNumber } from '@shared/core-utils'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
import { header } from 'express-validator'
|
||||||
|
import { getVideoWithAttributes } from '@server/helpers/video'
|
||||||
|
|
||||||
|
function isValidVideoPasswordHeader () {
|
||||||
|
return header('x-peertube-video-password')
|
||||||
|
.optional()
|
||||||
|
.isString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkVideoIsPasswordProtected (res: express.Response) {
|
||||||
|
const video = getVideoWithAttributes(res)
|
||||||
|
if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: 'Video is not password protected'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doesVideoPasswordExist (idArg: number | string, res: express.Response) {
|
||||||
|
const video = getVideoWithAttributes(res)
|
||||||
|
const id = forceNumber(idArg)
|
||||||
|
const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
|
||||||
|
|
||||||
|
if (!videoPassword) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.NOT_FOUND_404,
|
||||||
|
message: 'Video password not found'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.videoPassword = videoPassword
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isVideoPasswordDeletable (res: express.Response) {
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
const userAccount = user.Account
|
||||||
|
const video = res.locals.videoAll
|
||||||
|
|
||||||
|
// Check if the user who did the request is able to delete the video passwords
|
||||||
|
if (
|
||||||
|
user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator
|
||||||
|
video.VideoChannel.accountId !== userAccount.id // Not the video owner
|
||||||
|
) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
message: 'Cannot remove passwords of another user\'s video'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
|
||||||
|
|
||||||
|
if (passwordCount <= 1) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: 'Cannot delete the last password of the protected video'
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
isValidVideoPasswordHeader,
|
||||||
|
checkVideoIsPasswordProtected as isVideoPasswordProtected,
|
||||||
|
doesVideoPasswordExist,
|
||||||
|
isVideoPasswordDeletable
|
||||||
|
}
|
|
@ -20,6 +20,8 @@ import {
|
||||||
MVideoWithRights
|
MVideoWithRights
|
||||||
} from '@server/types/models'
|
} from '@server/types/models'
|
||||||
import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
|
import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
|
||||||
|
import { VideoPasswordModel } from '@server/models/video/video-password'
|
||||||
|
import { exists } from '@server/helpers/custom-validators/misc'
|
||||||
|
|
||||||
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
|
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
|
||||||
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
||||||
|
@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: {
|
||||||
}) {
|
}) {
|
||||||
const { req, res, video, paramId } = options
|
const { req, res, video, paramId } = options
|
||||||
|
|
||||||
if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) {
|
if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
|
||||||
return checkCanSeeAuthVideo(req, res, video)
|
return checkCanSeeUserAuthVideo({ req, res, video })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
|
return checkCanSeePasswordProtectedVideo({ req, res, video })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
|
if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
|
||||||
|
@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: {
|
||||||
throw new Error('Unknown video privacy when checking video right ' + video.url)
|
throw new Error('Unknown video privacy when checking video right ' + video.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) {
|
async function checkCanSeeUserAuthVideo (options: {
|
||||||
|
req: Request
|
||||||
|
res: Response
|
||||||
|
video: MVideoId | MVideoWithRights
|
||||||
|
}) {
|
||||||
|
const { req, res, video } = options
|
||||||
|
|
||||||
const fail = () => {
|
const fail = () => {
|
||||||
res.fail({
|
res.fail({
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await authenticatePromise(req, res)
|
await authenticatePromise({ req, res })
|
||||||
|
|
||||||
const user = res.locals.oauth?.token.User
|
const user = res.locals.oauth?.token.User
|
||||||
if (!user) return fail()
|
if (!user) return fail()
|
||||||
|
|
||||||
const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId
|
const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
|
||||||
? video as MVideoWithRights
|
|
||||||
: await VideoModel.loadFull(video.id)
|
|
||||||
|
|
||||||
const privacy = videoWithRights.privacy
|
const privacy = videoWithRights.privacy
|
||||||
|
|
||||||
|
@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
|
|
||||||
|
|
||||||
if (videoWithRights.isBlacklisted()) {
|
if (videoWithRights.isBlacklisted()) {
|
||||||
if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true
|
if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true
|
||||||
|
|
||||||
return fail()
|
return fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
|
if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
|
||||||
if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true
|
if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
|
||||||
|
|
||||||
return fail()
|
return fail()
|
||||||
}
|
}
|
||||||
|
@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
|
||||||
return fail()
|
return fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkCanSeePasswordProtectedVideo (options: {
|
||||||
|
req: Request
|
||||||
|
res: Response
|
||||||
|
video: MVideo
|
||||||
|
}) {
|
||||||
|
const { req, res, video } = options
|
||||||
|
|
||||||
|
const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
|
||||||
|
|
||||||
|
const videoPassword = req.header('x-peertube-video-password')
|
||||||
|
|
||||||
|
if (!exists(videoPassword)) {
|
||||||
|
const errorMessage = 'Please provide a password to access this password protected video'
|
||||||
|
const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
|
||||||
|
|
||||||
|
if (req.header('authorization')) {
|
||||||
|
await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType })
|
||||||
|
const user = res.locals.oauth?.token.User
|
||||||
|
|
||||||
|
if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
type: errorType,
|
||||||
|
message: errorMessage
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
|
||||||
|
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
|
||||||
|
message: 'Incorrect video password. Access to the video is denied.'
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) {
|
||||||
|
const isOwnedByUser = video.VideoChannel.Account.userId === user.id
|
||||||
|
|
||||||
|
return isOwnedByUser || user.hasRight(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
|
||||||
|
return video.VideoChannel?.Account?.userId
|
||||||
|
? video
|
||||||
|
: VideoModel.loadFull(video.id)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function checkCanAccessVideoStaticFiles (options: {
|
async function checkCanAccessVideoStaticFiles (options: {
|
||||||
|
@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
|
||||||
}) {
|
}) {
|
||||||
const { video, req, res } = options
|
const { video, req, res } = options
|
||||||
|
|
||||||
if (res.locals.oauth?.token.User) {
|
if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) {
|
||||||
return checkCanSeeVideo(options)
|
return checkCanSeeVideo(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
|
||||||
export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
|
export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
|
||||||
export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
|
export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
|
||||||
export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
|
export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
|
||||||
|
export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
|
||||||
|
|
||||||
export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
|
export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
|
||||||
export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
|
export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file'
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
|
import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
|
||||||
import { HttpStatusCode } from '@shared/models'
|
import { HttpStatusCode } from '@shared/models'
|
||||||
import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared'
|
import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
|
||||||
|
|
||||||
type LRUValue = {
|
type LRUValue = {
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
|
@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({
|
||||||
const ensureCanAccessVideoPrivateWebTorrentFiles = [
|
const ensureCanAccessVideoPrivateWebTorrentFiles = [
|
||||||
query('videoFileToken').optional().custom(exists),
|
query('videoFileToken').optional().custom(exists),
|
||||||
|
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(isSafePeerTubeFilenameWithoutExtension),
|
.customSanitizer(isSafePeerTubeFilenameWithoutExtension),
|
||||||
|
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTokenOrDie (req: express.Request, res: express.Response) {
|
function extractTokenOrDie (req: express.Request, res: express.Response) {
|
||||||
const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken
|
const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.fail({
|
return res.fail({
|
||||||
message: 'Bearer token is missing in headers or video file token is missing in URL query parameters',
|
message: 'Video password header, video file token query parameter and bearer token are all missing', //
|
||||||
status: HttpStatusCode.FORBIDDEN_403
|
status: HttpStatusCode.FORBIDDEN_403
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,8 @@ export * from './video-shares'
|
||||||
export * from './video-source'
|
export * from './video-source'
|
||||||
export * from './video-stats'
|
export * from './video-stats'
|
||||||
export * from './video-studio'
|
export * from './video-studio'
|
||||||
|
export * from './video-token'
|
||||||
export * from './video-transcoding'
|
export * from './video-transcoding'
|
||||||
export * from './videos'
|
export * from './videos'
|
||||||
export * from './video-channel-sync'
|
export * from './video-channel-sync'
|
||||||
|
export * from './video-passwords'
|
||||||
|
|
|
@ -10,7 +10,8 @@ import {
|
||||||
checkUserCanManageVideo,
|
checkUserCanManageVideo,
|
||||||
doesVideoCaptionExist,
|
doesVideoCaptionExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
isValidVideoIdParam
|
isValidVideoIdParam,
|
||||||
|
isValidVideoPasswordHeader
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
|
|
||||||
const addVideoCaptionValidator = [
|
const addVideoCaptionValidator = [
|
||||||
|
@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
|
||||||
const listVideoCaptionsValidator = [
|
const listVideoCaptionsValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||||
|
|
|
@ -14,7 +14,8 @@ import {
|
||||||
doesVideoCommentExist,
|
doesVideoCommentExist,
|
||||||
doesVideoCommentThreadExist,
|
doesVideoCommentThreadExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
isValidVideoIdParam
|
isValidVideoIdParam,
|
||||||
|
isValidVideoPasswordHeader
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
|
|
||||||
const listVideoCommentsValidator = [
|
const listVideoCommentsValidator = [
|
||||||
|
@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
|
||||||
|
|
||||||
const listVideoCommentThreadsValidator = [
|
const listVideoCommentThreadsValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
|
||||||
|
|
||||||
param('threadId')
|
param('threadId')
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
|
||||||
|
|
||||||
body('text')
|
body('text')
|
||||||
.custom(isValidVideoCommentText),
|
.custom(isValidVideoCommentText),
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
param('commentId').custom(isIdValid),
|
param('commentId').custom(isIdValid),
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
body('text').custom(isValidVideoCommentText),
|
body('text').custom(isValidVideoCommentText),
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
|
||||||
import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
|
import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
|
||||||
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
||||||
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
|
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
|
||||||
import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
|
import {
|
||||||
|
isValidPasswordProtectedPrivacy,
|
||||||
|
isVideoMagnetUriValid,
|
||||||
|
isVideoNameValid
|
||||||
|
} from '../../../helpers/custom-validators/videos'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { CONFIG } from '../../../initializers/config'
|
import { CONFIG } from '../../../initializers/config'
|
||||||
|
@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
.custom(isVideoNameValid).withMessage(
|
.custom(isVideoNameValid).withMessage(
|
||||||
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
|
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
|
||||||
),
|
),
|
||||||
|
body('videoPasswords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
|
@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
|
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
|
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
|
||||||
cleanUpReqFiles(req)
|
cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
VideoState
|
VideoState
|
||||||
} from '@shared/models'
|
} from '@shared/models'
|
||||||
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
||||||
import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos'
|
import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { CONFIG } from '../../../initializers/config'
|
import { CONFIG } from '../../../initializers/config'
|
||||||
|
@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
body('replaySettings.privacy')
|
body('replaySettings.privacy')
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isVideoPrivacyValid),
|
.custom(isVideoReplayPrivacyValid),
|
||||||
|
|
||||||
body('permanentLive')
|
body('permanentLive')
|
||||||
.optional()
|
.optional()
|
||||||
|
@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isLiveLatencyModeValid),
|
.custom(isLiveLatencyModeValid),
|
||||||
|
|
||||||
|
body('videoPasswords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
if (CONFIG.LIVE.ENABLED !== true) {
|
if (CONFIG.LIVE.ENABLED !== true) {
|
||||||
cleanUpReqFiles(req)
|
cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
|
||||||
body('replaySettings.privacy')
|
body('replaySettings.privacy')
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isVideoPrivacyValid),
|
.custom(isVideoReplayPrivacyValid),
|
||||||
|
|
||||||
body('latencyMode')
|
body('latencyMode')
|
||||||
.optional()
|
.optional()
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
import express from 'express'
|
||||||
|
import {
|
||||||
|
areValidationErrors,
|
||||||
|
doesVideoExist,
|
||||||
|
isVideoPasswordProtected,
|
||||||
|
isValidVideoIdParam,
|
||||||
|
doesVideoPasswordExist,
|
||||||
|
isVideoPasswordDeletable,
|
||||||
|
checkUserCanManageVideo
|
||||||
|
} from '../shared'
|
||||||
|
import { body, param } from 'express-validator'
|
||||||
|
import { isIdValid } from '@server/helpers/custom-validators/misc'
|
||||||
|
import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos'
|
||||||
|
import { UserRight } from '@shared/models'
|
||||||
|
|
||||||
|
const listVideoPasswordValidator = [
|
||||||
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
if (!await doesVideoExist(req.params.videoId, res)) return
|
||||||
|
if (!isVideoPasswordProtected(res)) return
|
||||||
|
|
||||||
|
// Check if the user who did the request is able to access video password list
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const updateVideoPasswordListValidator = [
|
||||||
|
body('passwords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
if (!await doesVideoExist(req.params.videoId, res)) return
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return
|
||||||
|
|
||||||
|
// Check if the user who did the request is able to update video passwords
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const removeVideoPasswordValidator = [
|
||||||
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
|
param('passwordId')
|
||||||
|
.custom(isIdValid),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
if (!await doesVideoExist(req.params.videoId, res)) return
|
||||||
|
if (!isVideoPasswordProtected(res)) return
|
||||||
|
if (!await doesVideoPasswordExist(req.params.passwordId, res)) return
|
||||||
|
if (!await isVideoPasswordDeletable(res)) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
listVideoPasswordValidator,
|
||||||
|
updateVideoPasswordListValidator,
|
||||||
|
removeVideoPasswordValidator
|
||||||
|
}
|
|
@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
|
||||||
await authenticatePromise(req, res)
|
await authenticatePromise({ req, res })
|
||||||
|
|
||||||
const user = res.locals.oauth ? res.locals.oauth.token.User : null
|
const user = res.locals.oauth ? res.locals.oauth.token.User : null
|
||||||
|
|
||||||
|
|
|
@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc'
|
||||||
import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
|
import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
|
||||||
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
|
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
|
||||||
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
|
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
|
||||||
import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
|
import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared'
|
||||||
|
|
||||||
const videoUpdateRateValidator = [
|
const videoUpdateRateValidator = [
|
||||||
isValidVideoIdParam('id'),
|
isValidVideoIdParam('id'),
|
||||||
|
|
||||||
body('rating')
|
body('rating')
|
||||||
.custom(isVideoRatingTypeValid),
|
.custom(isVideoRatingTypeValid),
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { VideoPrivacy } from '../../../../shared/models/videos'
|
||||||
|
import { HttpStatusCode } from '@shared/models'
|
||||||
|
import { exists } from '@server/helpers/custom-validators/misc'
|
||||||
|
|
||||||
|
const videoFileTokenValidator = [
|
||||||
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
const video = res.locals.onlyVideo
|
||||||
|
if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
|
||||||
|
return res.fail({
|
||||||
|
status: HttpStatusCode.UNAUTHORIZED_401,
|
||||||
|
message: 'Not authenticated'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
videoFileTokenValidator
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../
|
||||||
import {
|
import {
|
||||||
areVideoTagsValid,
|
areVideoTagsValid,
|
||||||
isScheduleVideoUpdatePrivacyValid,
|
isScheduleVideoUpdatePrivacyValid,
|
||||||
|
isValidPasswordProtectedPrivacy,
|
||||||
isVideoCategoryValid,
|
isVideoCategoryValid,
|
||||||
isVideoDescriptionValid,
|
isVideoDescriptionValid,
|
||||||
isVideoFileMimeTypeValid,
|
isVideoFileMimeTypeValid,
|
||||||
|
@ -55,7 +56,8 @@ import {
|
||||||
doesVideoChannelOfAccountExist,
|
doesVideoChannelOfAccountExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
doesVideoFileOfVideoExist,
|
doesVideoFileOfVideoExist,
|
||||||
isValidVideoIdParam
|
isValidVideoIdParam,
|
||||||
|
isValidVideoPasswordHeader
|
||||||
} from '../shared'
|
} from '../shared'
|
||||||
|
|
||||||
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
|
@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
body('channelId')
|
body('channelId')
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
body('videoPasswords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
|
@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
return cleanUpReqFiles(req)
|
return cleanUpReqFiles(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!videoFile.duration) await addDurationToVideo(videoFile)
|
if (!videoFile.duration) await addDurationToVideo(videoFile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
|
||||||
body('channelId')
|
body('channelId')
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
body('videoPasswords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
header('x-upload-content-length')
|
header('x-upload-content-length')
|
||||||
.isNumeric()
|
.isNumeric()
|
||||||
|
@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
|
||||||
const files = { videofile: [ videoFileMetadata ] }
|
const files = { videofile: [ videoFileMetadata ] }
|
||||||
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
|
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
|
||||||
|
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
|
||||||
|
|
||||||
// multer required unsetting the Content-Type, now we can set it for node-uploadx
|
// multer required unsetting the Content-Type, now we can set it for node-uploadx
|
||||||
req.headers['content-type'] = 'application/json; charset=utf-8'
|
req.headers['content-type'] = 'application/json; charset=utf-8'
|
||||||
// place previewfile in metadata so that uploadx saves it in .META
|
// place previewfile in metadata so that uploadx saves it in .META
|
||||||
|
@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toIntOrNull)
|
.customSanitizer(toIntOrNull)
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
body('videoPasswords')
|
||||||
|
.optional()
|
||||||
|
.isArray()
|
||||||
|
.withMessage('Video passwords should be an array.'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
|
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
|
||||||
if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
|
if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
const video = getVideoWithAttributes(res)
|
const video = getVideoWithAttributes(res)
|
||||||
if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
|
if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
|
||||||
return res.fail({ message: 'Cannot update privacy of a live that has already started' })
|
return res.fail({ message: 'Cannot update privacy of a live that has already started' })
|
||||||
|
@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
|
||||||
return [
|
return [
|
||||||
isValidVideoIdParam('id'),
|
isValidVideoIdParam('id'),
|
||||||
|
|
||||||
|
isValidVideoPasswordHeader(),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!await doesVideoExist(req.params.id, res, fetchType)) return
|
if (!await doesVideoExist(req.params.id, res, fetchType)) return
|
||||||
|
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
|
import { VideoModel } from './video'
|
||||||
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
|
import { ResultList, VideoPassword } from '@shared/models'
|
||||||
|
import { getSort, throwIfNotValid } from '../shared'
|
||||||
|
import { FindOptions, Transaction } from 'sequelize'
|
||||||
|
import { MVideoPassword } from '@server/types/models'
|
||||||
|
import { isPasswordValid } from '@server/helpers/custom-validators/videos'
|
||||||
|
import { pick } from '@shared/core-utils'
|
||||||
|
|
||||||
|
@DefaultScope(() => ({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: VideoModel.unscoped(),
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
@Table({
|
||||||
|
tableName: 'videoPassword',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [ 'videoId', 'password' ],
|
||||||
|
unique: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
|
||||||
|
@Column
|
||||||
|
password: string
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
updatedAt: Date
|
||||||
|
|
||||||
|
@ForeignKey(() => VideoModel)
|
||||||
|
@Column
|
||||||
|
videoId: number
|
||||||
|
|
||||||
|
@BelongsTo(() => VideoModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
onDelete: 'cascade'
|
||||||
|
})
|
||||||
|
Video: VideoModel
|
||||||
|
|
||||||
|
static async countByVideoId (videoId: number, t?: Transaction) {
|
||||||
|
const query: FindOptions = {
|
||||||
|
where: {
|
||||||
|
videoId
|
||||||
|
},
|
||||||
|
transaction: t
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoPasswordModel.count(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
|
||||||
|
const { id, videoId, t } = options
|
||||||
|
const query: FindOptions = {
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
videoId
|
||||||
|
},
|
||||||
|
transaction: t
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoPasswordModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listPasswords (options: {
|
||||||
|
start: number
|
||||||
|
count: number
|
||||||
|
sort: string
|
||||||
|
videoId: number
|
||||||
|
}): Promise<ResultList<MVideoPassword>> {
|
||||||
|
const { start, count, sort, videoId } = options
|
||||||
|
|
||||||
|
const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
|
||||||
|
where: { videoId },
|
||||||
|
order: getSort(sort),
|
||||||
|
offset: start,
|
||||||
|
limit: count
|
||||||
|
})
|
||||||
|
|
||||||
|
return { total, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
|
||||||
|
for (const password of passwords) {
|
||||||
|
await VideoPasswordModel.create({
|
||||||
|
password,
|
||||||
|
videoId
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
|
||||||
|
await VideoPasswordModel.destroy({
|
||||||
|
where: { videoId },
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deletePassword (passwordId: number, transaction?: Transaction) {
|
||||||
|
await VideoPasswordModel.destroy({
|
||||||
|
where: { id: passwordId },
|
||||||
|
transaction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async isACorrectPassword (options: {
|
||||||
|
videoId: number
|
||||||
|
password: string
|
||||||
|
}) {
|
||||||
|
const query = {
|
||||||
|
where: pick(options, [ 'videoId', 'password' ])
|
||||||
|
}
|
||||||
|
return VideoPasswordModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
toFormattedJSON (): VideoPassword {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
password: this.password,
|
||||||
|
videoId: this.videoId,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
|
||||||
// Internal video?
|
// Internal video?
|
||||||
if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
|
if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
|
||||||
|
|
||||||
if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
|
// Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
|
||||||
|
if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
|
||||||
|
return VideoPlaylistElementType.PRIVATE
|
||||||
|
}
|
||||||
|
|
||||||
if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
|
if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,7 @@ import { VideoFileModel } from './video-file'
|
||||||
import { VideoImportModel } from './video-import'
|
import { VideoImportModel } from './video-import'
|
||||||
import { VideoJobInfoModel } from './video-job-info'
|
import { VideoJobInfoModel } from './video-job-info'
|
||||||
import { VideoLiveModel } from './video-live'
|
import { VideoLiveModel } from './video-live'
|
||||||
|
import { VideoPasswordModel } from './video-password'
|
||||||
import { VideoPlaylistElementModel } from './video-playlist-element'
|
import { VideoPlaylistElementModel } from './video-playlist-element'
|
||||||
import { VideoShareModel } from './video-share'
|
import { VideoShareModel } from './video-share'
|
||||||
import { VideoSourceModel } from './video-source'
|
import { VideoSourceModel } from './video-source'
|
||||||
|
@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
})
|
})
|
||||||
VideoCaptions: VideoCaptionModel[]
|
VideoCaptions: VideoCaptionModel[]
|
||||||
|
|
||||||
|
@HasMany(() => VideoPasswordModel, {
|
||||||
|
foreignKey: {
|
||||||
|
name: 'videoId',
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
onDelete: 'cascade'
|
||||||
|
})
|
||||||
|
VideoPasswords: VideoPasswordModel[]
|
||||||
|
|
||||||
@HasOne(() => VideoJobInfoModel, {
|
@HasOne(() => VideoJobInfoModel, {
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'videoId',
|
name: 'videoId',
|
||||||
|
@ -1918,7 +1928,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
requiresAuth (options: {
|
requiresUserAuth (options: {
|
||||||
urlParamId: string
|
urlParamId: string
|
||||||
checkBlacklist: boolean
|
checkBlacklist: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
|
|
||||||
if (checkBlacklist && this.VideoBlacklist) return true
|
if (checkBlacklist && this.VideoBlacklist) return true
|
||||||
|
|
||||||
if (this.privacy !== VideoPrivacy.PUBLIC) {
|
if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
|
||||||
throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPrivateStaticPath () {
|
hasPrivateStaticPath () {
|
||||||
|
|
|
@ -143,7 +143,7 @@ describe('Test video lives API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with a bad privacy for replay settings', async function () {
|
it('Should fail with a bad privacy for replay settings', async function () {
|
||||||
const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } }
|
const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } }
|
||||||
|
|
||||||
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
|
||||||
})
|
})
|
||||||
|
@ -472,7 +472,7 @@ describe('Test video lives API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with a bad privacy for replay settings', async function () {
|
it('Should fail with a bad privacy for replay settings', async function () {
|
||||||
const fields = { saveReplay: true, replaySettings: { privacy: 5 } }
|
const fields = { saveReplay: true, replaySettings: { privacy: 999 } }
|
||||||
|
|
||||||
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,609 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
import {
|
||||||
|
FIXTURE_URLS,
|
||||||
|
checkBadCountPagination,
|
||||||
|
checkBadSortPagination,
|
||||||
|
checkBadStartPagination,
|
||||||
|
checkUploadVideoParam
|
||||||
|
} from '@server/tests/shared'
|
||||||
|
import { root } from '@shared/core-utils'
|
||||||
|
import {
|
||||||
|
HttpStatusCode,
|
||||||
|
PeerTubeProblemDocument,
|
||||||
|
ServerErrorCode,
|
||||||
|
VideoCreateResult,
|
||||||
|
VideoPrivacy
|
||||||
|
} from '@shared/models'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
makePostBodyRequest,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
describe('Test video passwords validator', function () {
|
||||||
|
let path: string
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let userAccessToken = ''
|
||||||
|
let video: VideoCreateResult
|
||||||
|
let channelId: number
|
||||||
|
let publicVideo: VideoCreateResult
|
||||||
|
let commentId: number
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(50000)
|
||||||
|
|
||||||
|
server = await createSingleServer(1)
|
||||||
|
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
|
||||||
|
await server.config.updateCustomSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
live: {
|
||||||
|
enabled: true,
|
||||||
|
latencySetting: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
allowReplay: false
|
||||||
|
},
|
||||||
|
import: {
|
||||||
|
videos: {
|
||||||
|
http:{
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
userAccessToken = await server.users.generateUserAndToken('user1')
|
||||||
|
|
||||||
|
{
|
||||||
|
const body = await server.users.getMyInfo()
|
||||||
|
channelId = body.videoChannels[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
video = await server.videos.quickUpload({
|
||||||
|
name: 'password protected video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ 'password1', 'password2' ]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
path = '/api/v1/videos/'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkVideoPasswordOptions (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
token: string
|
||||||
|
videoPasswords: string[]
|
||||||
|
expectedStatus: HttpStatusCode
|
||||||
|
mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live'
|
||||||
|
}) {
|
||||||
|
const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options
|
||||||
|
const attaches = {
|
||||||
|
fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
|
||||||
|
}
|
||||||
|
const baseCorrectParams = {
|
||||||
|
name: 'my super name',
|
||||||
|
category: 5,
|
||||||
|
licence: 1,
|
||||||
|
language: 'pt',
|
||||||
|
nsfw: false,
|
||||||
|
commentsEnabled: true,
|
||||||
|
downloadEnabled: true,
|
||||||
|
waitTranscoding: true,
|
||||||
|
description: 'my super description',
|
||||||
|
support: 'my super support text',
|
||||||
|
tags: [ 'tag1', 'tag2' ],
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
channelId,
|
||||||
|
originallyPublishedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
if (mode === 'uploadLegacy') {
|
||||||
|
const fields = { ...baseCorrectParams, videoPasswords }
|
||||||
|
return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'uploadResumable') {
|
||||||
|
const fields = { ...baseCorrectParams, videoPasswords }
|
||||||
|
return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'import') {
|
||||||
|
const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords }
|
||||||
|
return server.imports.importVideo({ attributes, expectedStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'updateVideo') {
|
||||||
|
const attributes = { ...baseCorrectParams, videoPasswords }
|
||||||
|
return server.videos.update({ token, expectedStatus, id: video.id, attributes })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'updatePasswords') {
|
||||||
|
return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'live') {
|
||||||
|
const fields = { ...baseCorrectParams, videoPasswords }
|
||||||
|
|
||||||
|
return server.live.create({ fields, expectedStatus })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy without providing a password', async function () {
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords: undefined,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy and an empty password list', async function () {
|
||||||
|
const videoPasswords = []
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy and a too short password', async function () {
|
||||||
|
const videoPasswords = [ 'p' ]
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy and a too long password', async function () {
|
||||||
|
const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ]
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy and an empty password', async function () {
|
||||||
|
const videoPasswords = [ '' ]
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a password protected privacy and duplicated passwords', async function () {
|
||||||
|
const videoPasswords = [ 'password', 'password' ]
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: server.accessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mode === 'updatePasswords') {
|
||||||
|
it('Should fail for an unauthenticated user', async function () {
|
||||||
|
const videoPasswords = [ 'password' ]
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: null,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for an unauthorized user', async function () {
|
||||||
|
const videoPasswords = [ 'password' ]
|
||||||
|
await checkVideoPasswordOptions({
|
||||||
|
server,
|
||||||
|
token: userAccessToken,
|
||||||
|
videoPasswords,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
mode
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should succeed with a password protected privacy and correct passwords', async function () {
|
||||||
|
const videoPasswords = [ 'password1', 'password2' ]
|
||||||
|
const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo'
|
||||||
|
? HttpStatusCode.NO_CONTENT_204
|
||||||
|
: HttpStatusCode.OK_200
|
||||||
|
|
||||||
|
await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('When adding or updating a video', function () {
|
||||||
|
describe('Resumable upload', function () {
|
||||||
|
validateVideoPasswordList('uploadResumable')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Legacy upload', function () {
|
||||||
|
validateVideoPasswordList('uploadLegacy')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When importing a video', function () {
|
||||||
|
validateVideoPasswordList('import')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When updating a video', function () {
|
||||||
|
validateVideoPasswordList('updateVideo')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When updating the password list of a video', function () {
|
||||||
|
validateVideoPasswordList('updatePasswords')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When creating a live', function () {
|
||||||
|
validateVideoPasswordList('live')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function checkVideoAccessOptions (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
token?: string
|
||||||
|
videoPassword?: string
|
||||||
|
expectedStatus: HttpStatusCode
|
||||||
|
mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token'
|
||||||
|
}) {
|
||||||
|
const { server, token = null, videoPassword, expectedStatus, mode } = options
|
||||||
|
|
||||||
|
if (mode === 'get') {
|
||||||
|
return server.videos.get({ id: video.id, expectedStatus })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'getWithToken') {
|
||||||
|
return server.videos.getWithToken({
|
||||||
|
id: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'getWithPassword') {
|
||||||
|
return server.videos.getWithPassword({
|
||||||
|
id: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus,
|
||||||
|
password: videoPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'rate') {
|
||||||
|
return server.videos.rate({
|
||||||
|
id: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus,
|
||||||
|
rating: 'like',
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'createThread') {
|
||||||
|
const fields = { text: 'super comment' }
|
||||||
|
const headers = videoPassword !== undefined && videoPassword !== null
|
||||||
|
? { 'x-peertube-video-password': videoPassword }
|
||||||
|
: undefined
|
||||||
|
const body = await makePostBodyRequest({
|
||||||
|
url: server.url,
|
||||||
|
path: path + video.uuid + '/comment-threads',
|
||||||
|
token,
|
||||||
|
fields,
|
||||||
|
headers,
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
return JSON.parse(body.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'replyThread') {
|
||||||
|
const fields = { text: 'super reply' }
|
||||||
|
const headers = videoPassword !== undefined && videoPassword !== null
|
||||||
|
? { 'x-peertube-video-password': videoPassword }
|
||||||
|
: undefined
|
||||||
|
return makePostBodyRequest({
|
||||||
|
url: server.url,
|
||||||
|
path: path + video.uuid + '/comments/' + commentId,
|
||||||
|
token,
|
||||||
|
fields,
|
||||||
|
headers,
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mode === 'listThreads') {
|
||||||
|
return server.comments.listThreads({
|
||||||
|
videoId: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus,
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'listCaptions') {
|
||||||
|
return server.captions.list({
|
||||||
|
videoId: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus,
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'token') {
|
||||||
|
return server.videoToken.create({
|
||||||
|
videoId: video.id,
|
||||||
|
token,
|
||||||
|
expectedStatus,
|
||||||
|
videoPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') {
|
||||||
|
const serverCode = mode === 'providePassword'
|
||||||
|
? ServerErrorCode.VIDEO_REQUIRES_PASSWORD
|
||||||
|
: ServerErrorCode.INCORRECT_VIDEO_PASSWORD
|
||||||
|
|
||||||
|
const message = mode === 'providePassword'
|
||||||
|
? 'Please provide a password to access this password protected video'
|
||||||
|
: 'Incorrect video password. Access to the video is denied.'
|
||||||
|
|
||||||
|
if (!error.code) {
|
||||||
|
error = JSON.parse(error.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error.code).to.equal(serverCode)
|
||||||
|
expect(error.detail).to.equal(message)
|
||||||
|
expect(error.error).to.equal(message)
|
||||||
|
|
||||||
|
expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') {
|
||||||
|
const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
|
||||||
|
let tokens: string[]
|
||||||
|
if (!requiresUserAuth) {
|
||||||
|
it('Should fail without providing a password for an unlogged user', async function () {
|
||||||
|
const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
|
||||||
|
const error = body as unknown as PeerTubeProblemDocument
|
||||||
|
|
||||||
|
checkVideoError(error, 'providePassword')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should fail without providing a password for an unauthorised user', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithToken' : mode
|
||||||
|
|
||||||
|
const body = await checkVideoAccessOptions({
|
||||||
|
server,
|
||||||
|
token: userAccessToken,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
mode: tmp
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = body as unknown as PeerTubeProblemDocument
|
||||||
|
|
||||||
|
checkVideoError(error, 'providePassword')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail if a wrong password is entered', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithPassword' : mode
|
||||||
|
tokens = [ userAccessToken, server.accessToken ]
|
||||||
|
|
||||||
|
if (!requiresUserAuth) tokens.push(null)
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const body = await checkVideoAccessOptions({
|
||||||
|
server,
|
||||||
|
token,
|
||||||
|
videoPassword: 'toto',
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
mode: tmp
|
||||||
|
})
|
||||||
|
const error = body as unknown as PeerTubeProblemDocument
|
||||||
|
|
||||||
|
checkVideoError(error, 'incorrectPassword')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail if an empty password is entered', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithPassword' : mode
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const body = await checkVideoAccessOptions({
|
||||||
|
server,
|
||||||
|
token,
|
||||||
|
videoPassword: '',
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
mode: tmp
|
||||||
|
})
|
||||||
|
const error = body as unknown as PeerTubeProblemDocument
|
||||||
|
|
||||||
|
checkVideoError(error, 'incorrectPassword')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail if an inccorect password containing the correct password is entered', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithPassword' : mode
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const body = await checkVideoAccessOptions({
|
||||||
|
server,
|
||||||
|
token,
|
||||||
|
videoPassword: 'password11',
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
mode: tmp
|
||||||
|
})
|
||||||
|
const error = body as unknown as PeerTubeProblemDocument
|
||||||
|
|
||||||
|
checkVideoError(error, 'incorrectPassword')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed without providing a password for an authorised user', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithToken' : mode
|
||||||
|
const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
|
||||||
|
|
||||||
|
const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp })
|
||||||
|
|
||||||
|
if (mode === 'createThread') commentId = body.comment.id
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed using correct passwords', async function () {
|
||||||
|
const tmp = mode === 'get' ? 'getWithPassword' : mode
|
||||||
|
const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp })
|
||||||
|
await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('When accessing password protected video', function () {
|
||||||
|
|
||||||
|
describe('For getting a password protected video', function () {
|
||||||
|
validateVideoAccess('get')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For rating a video', function () {
|
||||||
|
validateVideoAccess('rate')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For creating a thread', function () {
|
||||||
|
validateVideoAccess('createThread')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For replying to a thread', function () {
|
||||||
|
validateVideoAccess('replyThread')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For listing threads', function () {
|
||||||
|
validateVideoAccess('listThreads')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For getting captions', function () {
|
||||||
|
validateVideoAccess('listCaptions')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('For creating video file token', function () {
|
||||||
|
validateVideoAccess('token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When listing passwords', function () {
|
||||||
|
it('Should fail with a bad start pagination', async function () {
|
||||||
|
await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a bad count pagination', async function () {
|
||||||
|
await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an incorrect sort', async function () {
|
||||||
|
await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for unauthenticated user', async function () {
|
||||||
|
await server.videoPasswords.list({
|
||||||
|
token: null,
|
||||||
|
expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
|
||||||
|
videoId: video.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for unauthorized user', async function () {
|
||||||
|
await server.videoPasswords.list({
|
||||||
|
token: userAccessToken,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
videoId: video.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct parameters', async function () {
|
||||||
|
await server.videoPasswords.list({
|
||||||
|
token: server.accessToken,
|
||||||
|
expectedStatus: HttpStatusCode.OK_200,
|
||||||
|
videoId: video.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When deleting a password', async function () {
|
||||||
|
const passwords = (await server.videoPasswords.list({ videoId: video.id })).data
|
||||||
|
|
||||||
|
it('Should fail with wrong password id', async function () {
|
||||||
|
await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for unauthenticated user', async function () {
|
||||||
|
await server.videoPasswords.remove({
|
||||||
|
id: passwords[0].id,
|
||||||
|
token: null,
|
||||||
|
videoId: video.id,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for unauthorized user', async function () {
|
||||||
|
await server.videoPasswords.remove({
|
||||||
|
id: passwords[0].id,
|
||||||
|
token: userAccessToken,
|
||||||
|
videoId: video.id,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for non password protected video', async function () {
|
||||||
|
publicVideo = await server.videos.quickUpload({ name: 'public video' })
|
||||||
|
await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for password not linked to correct video', async function () {
|
||||||
|
const video2 = await server.videos.quickUpload({
|
||||||
|
name: 'password protected video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ 'password1', 'password2' ]
|
||||||
|
})
|
||||||
|
await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with correct parameter', async function () {
|
||||||
|
await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail for last password of a video', async function () {
|
||||||
|
await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
|
||||||
|
|
||||||
describe('Test video tokens', function () {
|
describe('Test video tokens', function () {
|
||||||
let server: PeerTubeServer
|
let server: PeerTubeServer
|
||||||
let videoId: string
|
let privateVideoId: string
|
||||||
|
let passwordProtectedVideoId: string
|
||||||
let userToken: string
|
let userToken: string
|
||||||
|
|
||||||
|
const videoPassword = 'password'
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
|
@ -15,27 +18,50 @@ describe('Test video tokens', function () {
|
||||||
|
|
||||||
server = await createSingleServer(1)
|
server = await createSingleServer(1)
|
||||||
await setAccessTokensToServers([ server ])
|
await setAccessTokensToServers([ server ])
|
||||||
|
{
|
||||||
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
|
const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
|
||||||
videoId = uuid
|
privateVideoId = uuid
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'password protected video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ videoPassword ]
|
||||||
|
})
|
||||||
|
passwordProtectedVideoId = uuid
|
||||||
|
}
|
||||||
userToken = await server.users.generateUserAndToken('user1')
|
userToken = await server.users.generateUserAndToken('user1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not generate tokens for unauthenticated user', async function () {
|
it('Should not generate tokens on private video for unauthenticated user', async function () {
|
||||||
await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not generate tokens of unknown video', async function () {
|
it('Should not generate tokens of unknown video', async function () {
|
||||||
await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should not generate tokens with incorrect password', async function () {
|
||||||
|
await server.videoToken.create({
|
||||||
|
videoId: passwordProtectedVideoId,
|
||||||
|
token: null,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
videoPassword: 'incorrectPassword'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('Should not generate tokens of a non owned video', async function () {
|
it('Should not generate tokens of a non owned video', async function () {
|
||||||
await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should generate token', async function () {
|
it('Should generate token', async function () {
|
||||||
await server.videoToken.create({ videoId })
|
await server.videoToken.create({ videoId: privateVideoId })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate token on password protected video', async function () {
|
||||||
|
await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null })
|
||||||
|
await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken })
|
||||||
|
await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword })
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
|
|
@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () {
|
||||||
describe('VOD', function () {
|
describe('VOD', function () {
|
||||||
let privateVideoUUID: string
|
let privateVideoUUID: string
|
||||||
let publicVideoUUID: string
|
let publicVideoUUID: string
|
||||||
|
let passwordProtectedVideoUUID: string
|
||||||
let userPrivateVideoUUID: string
|
let userPrivateVideoUUID: string
|
||||||
|
|
||||||
|
const correctPassword = 'my super password'
|
||||||
|
const correctPasswordHeader = { 'x-peertube-video-password': correctPassword }
|
||||||
|
const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getSampleFileUrls (videoId: string) {
|
async function getSampleFileUrls (videoId: string) {
|
||||||
|
@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await checkPrivateVODFiles(privateVideoUUID)
|
await checkPrivateVODFiles(privateVideoUUID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should upload a password protected video and have appropriate object storage ACL', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
{
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ correctPassword ]
|
||||||
|
})
|
||||||
|
passwordProtectedVideoUUID = uuid
|
||||||
|
}
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await checkPrivateVODFiles(passwordProtectedVideoUUID)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should upload a public video and have appropriate object storage ACL', async function () {
|
it('Should upload a public video and have appropriate object storage ACL', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should not get files without appropriate password or appropriate OAuth token', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
|
||||||
|
|
||||||
|
await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await makeRawRequest({
|
||||||
|
url: webTorrentFile,
|
||||||
|
token: null,
|
||||||
|
headers: incorrectPasswordHeader,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({
|
||||||
|
url: webTorrentFile,
|
||||||
|
token: null,
|
||||||
|
headers: correctPasswordHeader,
|
||||||
|
expectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
|
||||||
|
await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await makeRawRequest({
|
||||||
|
url: hlsFile,
|
||||||
|
token: null,
|
||||||
|
headers: incorrectPasswordHeader,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({
|
||||||
|
url: hlsFile,
|
||||||
|
token: null,
|
||||||
|
headers: correctPasswordHeader,
|
||||||
|
expectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('Should not get HLS file of another video', async function () {
|
it('Should not get HLS file of another video', async function () {
|
||||||
this.timeout(60000)
|
this.timeout(60000)
|
||||||
|
|
||||||
|
@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should correctly check OAuth or video file token', async function () {
|
it('Should correctly check OAuth, video file token of private video', async function () {
|
||||||
this.timeout(60000)
|
this.timeout(60000)
|
||||||
|
|
||||||
const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
|
const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
|
||||||
|
@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () {
|
||||||
|
|
||||||
await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should correctly check OAuth, video file token or video password of password protected video', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
|
||||||
|
const goodVideoFileToken = await server.videoToken.getVideoFileToken({
|
||||||
|
videoId: passwordProtectedVideoUUID,
|
||||||
|
videoPassword: correctPassword
|
||||||
|
})
|
||||||
|
|
||||||
|
const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
|
||||||
|
|
||||||
|
for (const url of [ hlsFile, webTorrentFile ]) {
|
||||||
|
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
|
await makeRawRequest({
|
||||||
|
url,
|
||||||
|
headers: incorrectPasswordHeader,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () {
|
||||||
let permanentLiveId: string
|
let permanentLiveId: string
|
||||||
let permanentLive: LiveVideo
|
let permanentLive: LiveVideo
|
||||||
|
|
||||||
|
let passwordProtectedLiveId: string
|
||||||
|
let passwordProtectedLive: LiveVideo
|
||||||
|
|
||||||
|
const correctPassword = 'my super password'
|
||||||
|
|
||||||
let unrelatedFileToken: string
|
let unrelatedFileToken: string
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function checkLiveFiles (live: LiveVideo, liveId: string) {
|
async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) {
|
||||||
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
||||||
await server.live.waitUntilPublished({ videoId: liveId })
|
await server.live.waitUntilPublished({ videoId: liveId })
|
||||||
|
|
||||||
const video = await server.videos.getWithToken({ id: liveId })
|
const video = videoPassword
|
||||||
const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
? await server.videos.getWithPassword({ id: liveId, password: videoPassword })
|
||||||
|
: await server.videos.getWithToken({ id: liveId })
|
||||||
|
|
||||||
|
const fileToken = videoPassword
|
||||||
|
? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword })
|
||||||
|
: await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
||||||
|
|
||||||
const hls = video.streamingPlaylists[0]
|
const hls = video.streamingPlaylists[0]
|
||||||
|
|
||||||
|
@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () {
|
||||||
|
|
||||||
await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
if (videoPassword) {
|
||||||
|
await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
}
|
||||||
await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
if (videoPassword) {
|
||||||
|
await makeRawRequest({
|
||||||
|
url,
|
||||||
|
headers: { 'x-peertube-video-password': 'incorrectPassword' },
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
|
||||||
permanentLiveId = video.uuid
|
permanentLiveId = video.uuid
|
||||||
permanentLive = live
|
permanentLive = live
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { video, live } = await server.live.quickCreate({
|
||||||
|
saveReplay: false,
|
||||||
|
permanentLive: false,
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ correctPassword ]
|
||||||
|
})
|
||||||
|
passwordProtectedLiveId = video.uuid
|
||||||
|
passwordProtectedLive = live
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should create a private normal live and have a private static path', async function () {
|
it('Should create a private normal live and have a private static path', async function () {
|
||||||
|
@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () {
|
||||||
await checkLiveFiles(permanentLive, permanentLiveId)
|
await checkLiveFiles(permanentLive, permanentLiveId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should create a password protected live and have a private static path', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword)
|
||||||
|
})
|
||||||
|
|
||||||
it('Should reinject video file token in permanent live', async function () {
|
it('Should reinject video file token in permanent live', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
VideoPasswordsCommand,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultAccountAvatar,
|
||||||
|
setDefaultChannelAvatar
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
import { VideoPrivacy } from '@shared/models'
|
||||||
|
|
||||||
|
describe('Test video passwords', function () {
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let videoUUID: string
|
||||||
|
|
||||||
|
let userAccessTokenServer1: string
|
||||||
|
|
||||||
|
let videoPasswords: string[] = []
|
||||||
|
let command: VideoPasswordsCommand
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
server = await createSingleServer(1)
|
||||||
|
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
videoPasswords.push(`password ${i + 1}`)
|
||||||
|
}
|
||||||
|
const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } })
|
||||||
|
videoUUID = uuid
|
||||||
|
|
||||||
|
await setDefaultChannelAvatar(server)
|
||||||
|
await setDefaultAccountAvatar(server)
|
||||||
|
|
||||||
|
userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
|
||||||
|
await setDefaultChannelAvatar(server, 'user1_channel')
|
||||||
|
await setDefaultAccountAvatar(server, userAccessTokenServer1)
|
||||||
|
|
||||||
|
command = server.videoPasswords
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should list video passwords', async function () {
|
||||||
|
const body = await command.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
expect(body.total).to.equal(10)
|
||||||
|
expect(body.data).to.be.an('array')
|
||||||
|
expect(body.data).to.have.lengthOf(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should filter passwords on this video', async function () {
|
||||||
|
const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' })
|
||||||
|
|
||||||
|
expect(body.total).to.equal(10)
|
||||||
|
expect(body.data).to.be.an('array')
|
||||||
|
expect(body.data).to.have.lengthOf(2)
|
||||||
|
expect(body.data[0].password).to.equal('password 4')
|
||||||
|
expect(body.data[1].password).to.equal('password 5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update password for this video', async function () {
|
||||||
|
videoPasswords = [ 'my super new password 1', 'my super new password 2' ]
|
||||||
|
|
||||||
|
await command.updateAll({ videoId: videoUUID, passwords: videoPasswords })
|
||||||
|
const body = await command.list({ videoId: videoUUID })
|
||||||
|
expect(body.total).to.equal(2)
|
||||||
|
expect(body.data).to.be.an('array')
|
||||||
|
expect(body.data).to.have.lengthOf(2)
|
||||||
|
expect(body.data[0].password).to.equal('my super new password 2')
|
||||||
|
expect(body.data[1].password).to.equal('my super new password 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should delete one password', async function () {
|
||||||
|
{
|
||||||
|
const body = await command.list({ videoId: videoUUID })
|
||||||
|
expect(body.total).to.equal(2)
|
||||||
|
expect(body.data).to.be.an('array')
|
||||||
|
expect(body.data).to.have.lengthOf(2)
|
||||||
|
await command.remove({ id: body.data[0].id, videoId: videoUUID })
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const body = await command.list({ videoId: videoUUID })
|
||||||
|
|
||||||
|
expect(body.total).to.equal(1)
|
||||||
|
expect(body.data).to.be.an('array')
|
||||||
|
expect(body.data).to.have.lengthOf(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -474,7 +474,7 @@ describe('Test video playlists', function () {
|
||||||
await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
|
await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should get unlisted plyaylist using uuid or shortUUID', async function () {
|
it('Should get unlisted playlist using uuid or shortUUID', async function () {
|
||||||
await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
|
await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
|
||||||
await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
|
await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
|
||||||
})
|
})
|
||||||
|
@ -686,7 +686,7 @@ describe('Test video playlists', function () {
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should update the element type if the video is private', async function () {
|
it('Should update the element type if the video is private/password protected', async function () {
|
||||||
this.timeout(20000)
|
this.timeout(20000)
|
||||||
|
|
||||||
const name = 'video 89'
|
const name = 'video 89'
|
||||||
|
@ -702,6 +702,19 @@ describe('Test video playlists', function () {
|
||||||
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
|
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await servers[0].videos.update({
|
||||||
|
id: video1,
|
||||||
|
attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
|
||||||
|
await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
|
||||||
|
await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
|
||||||
|
await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
|
await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
|
@ -90,7 +90,7 @@ describe('Test video static file privacy', function () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('Should upload a private/internal video and have a private static path', async function () {
|
it('Should upload a private/internal/password protected video and have a private static path', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
|
for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
|
||||||
|
@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
|
||||||
|
|
||||||
await checkPrivateFiles(uuid)
|
await checkPrivateFiles(uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ 'my super password' ]
|
||||||
|
})
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await checkPrivateFiles(uuid)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should upload a public video and update it as private/internal to have a private static path', async function () {
|
it('Should upload a public video and update it as private/internal to have a private static path', async function () {
|
||||||
|
@ -185,8 +194,9 @@ describe('Test video static file privacy', function () {
|
||||||
expectedStatus: HttpStatusCode
|
expectedStatus: HttpStatusCode
|
||||||
token: string
|
token: string
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
|
videoPassword?: string
|
||||||
}) {
|
}) {
|
||||||
const { id, expectedStatus, token, videoFileToken } = options
|
const { id, expectedStatus, token, videoFileToken, videoPassword } = options
|
||||||
|
|
||||||
const video = await server.videos.getWithToken({ id })
|
const video = await server.videos.getWithToken({ id })
|
||||||
|
|
||||||
|
@ -196,6 +206,12 @@ describe('Test video static file privacy', function () {
|
||||||
|
|
||||||
await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
|
await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
|
||||||
await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
|
await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
|
||||||
|
|
||||||
|
if (videoPassword) {
|
||||||
|
const headers = { 'x-peertube-video-password': videoPassword }
|
||||||
|
await makeRawRequest({ url: file.fileUrl, headers, expectedStatus })
|
||||||
|
await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hls = video.streamingPlaylists[0]
|
const hls = video.streamingPlaylists[0]
|
||||||
|
@ -204,6 +220,12 @@ describe('Test video static file privacy', function () {
|
||||||
|
|
||||||
await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
|
await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
|
||||||
await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
|
await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
|
||||||
|
|
||||||
|
if (videoPassword) {
|
||||||
|
const headers = { 'x-peertube-video-password': videoPassword }
|
||||||
|
await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus })
|
||||||
|
await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
|
@ -216,13 +238,53 @@ describe('Test video static file privacy', function () {
|
||||||
it('Should not be able to access a private video files without OAuth token and file token', async function () {
|
it('Should not be able to access a private video files without OAuth token and file token', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL })
|
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
|
||||||
await waitJobs([ server ])
|
await waitJobs([ server ])
|
||||||
|
|
||||||
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null })
|
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () {
|
it('Should not be able to access password protected video files without OAuth token, file token and password', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
const videoPassword = 'my super password'
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'password protected video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ videoPassword ]
|
||||||
|
})
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await checkVideoFiles({
|
||||||
|
id: uuid,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
token: null,
|
||||||
|
videoFileToken: null,
|
||||||
|
videoPassword: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
const videoPassword = 'my super password'
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'password protected video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ videoPassword ]
|
||||||
|
})
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await checkVideoFiles({
|
||||||
|
id: uuid,
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
token: userToken,
|
||||||
|
videoFileToken: unrelatedFileToken,
|
||||||
|
videoPassword: 'incorrectPassword'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
|
const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
|
||||||
|
@ -247,6 +309,23 @@ describe('Test video static file privacy', function () {
|
||||||
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
|
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
const videoPassword = 'my super password'
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({
|
||||||
|
name: 'video',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ videoPassword ]
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword })
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword })
|
||||||
|
})
|
||||||
|
|
||||||
it('Should reinject video file token', async function () {
|
it('Should reinject video file token', async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
|
||||||
|
@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
|
||||||
let permanentLiveId: string
|
let permanentLiveId: string
|
||||||
let permanentLive: LiveVideo
|
let permanentLive: LiveVideo
|
||||||
|
|
||||||
|
let passwordProtectedLiveId: string
|
||||||
|
let passwordProtectedLive: LiveVideo
|
||||||
|
|
||||||
|
const correctPassword = 'my super password'
|
||||||
|
|
||||||
let unrelatedFileToken: string
|
let unrelatedFileToken: string
|
||||||
|
|
||||||
async function checkLiveFiles (live: LiveVideo, liveId: string) {
|
async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) {
|
||||||
|
const { live, liveId, videoPassword } = options
|
||||||
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
||||||
await server.live.waitUntilPublished({ videoId: liveId })
|
await server.live.waitUntilPublished({ videoId: liveId })
|
||||||
|
|
||||||
const video = await server.videos.getWithToken({ id: liveId })
|
const video = await server.videos.getWithToken({ id: liveId })
|
||||||
|
|
||||||
const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
|
||||||
|
|
||||||
const hls = video.streamingPlaylists[0]
|
const hls = video.streamingPlaylists[0]
|
||||||
|
@ -314,6 +400,16 @@ describe('Test video static file privacy', function () {
|
||||||
await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
|
||||||
|
if (videoPassword) {
|
||||||
|
await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
await makeRawRequest({
|
||||||
|
url,
|
||||||
|
headers: { 'x-peertube-video-password': 'incorrectPassword' },
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopFfmpeg(ffmpegCommand)
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
|
||||||
permanentLiveId = video.uuid
|
permanentLiveId = video.uuid
|
||||||
permanentLive = live
|
permanentLive = live
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { video, live } = await server.live.quickCreate({
|
||||||
|
saveReplay: false,
|
||||||
|
permanentLive: false,
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ correctPassword ]
|
||||||
|
})
|
||||||
|
passwordProtectedLiveId = video.uuid
|
||||||
|
passwordProtectedLive = live
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should create a private normal live and have a private static path', async function () {
|
it('Should create a private normal live and have a private static path', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
await checkLiveFiles(normalLive, normalLiveId)
|
await checkLiveFiles({ live: normalLive, liveId: normalLiveId })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should create a private permanent live and have a private static path', async function () {
|
it('Should create a private permanent live and have a private static path', async function () {
|
||||||
this.timeout(240000)
|
this.timeout(240000)
|
||||||
|
|
||||||
await checkLiveFiles(permanentLive, permanentLiveId)
|
await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should create a password protected live and have a private static path', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should reinject video file token on permanent live', async function () {
|
it('Should reinject video file token on permanent live', async function () {
|
||||||
|
|
|
@ -56,6 +56,7 @@ describe('Test a client controllers', function () {
|
||||||
let privateVideoId: string
|
let privateVideoId: string
|
||||||
let internalVideoId: string
|
let internalVideoId: string
|
||||||
let unlistedVideoId: string
|
let unlistedVideoId: string
|
||||||
|
let passwordProtectedVideoId: string
|
||||||
|
|
||||||
let playlistIds: (string | number)[] = []
|
let playlistIds: (string | number)[] = []
|
||||||
|
|
||||||
|
@ -92,7 +93,12 @@ describe('Test a client controllers', function () {
|
||||||
{
|
{
|
||||||
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
|
({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
|
||||||
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
|
({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
|
||||||
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }))
|
({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
|
||||||
|
({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
|
||||||
|
name: 'password protected',
|
||||||
|
privacy: VideoPrivacy.PASSWORD_PROTECTED,
|
||||||
|
videoPasswords: [ 'password' ]
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist
|
// Playlist
|
||||||
|
@ -502,9 +508,9 @@ describe('Test a client controllers', function () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should not display internal/private video', async function () {
|
it('Should not display internal/private/password protected video', async function () {
|
||||||
for (const basePath of watchVideoBasePaths) {
|
for (const basePath of watchVideoBasePaths) {
|
||||||
for (const id of [ privateVideoId, internalVideoId ]) {
|
for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
|
||||||
const res = await makeGetRequest({
|
const res = await makeGetRequest({
|
||||||
url: servers[0].url,
|
url: servers[0].url,
|
||||||
path: basePath + id,
|
path: basePath + id,
|
||||||
|
@ -514,6 +520,7 @@ describe('Test a client controllers', function () {
|
||||||
|
|
||||||
expect(res.text).to.not.contain('internal')
|
expect(res.text).to.not.contain('internal')
|
||||||
expect(res.text).to.not.contain('private')
|
expect(res.text).to.not.contain('private')
|
||||||
|
expect(res.text).to.not.contain('password protected')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -99,6 +99,13 @@ describe('Test syndication feeds', () => {
|
||||||
await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
|
await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
|
||||||
|
const { id } = await servers[0].videos.upload({ attributes })
|
||||||
|
|
||||||
|
await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
|
||||||
|
}
|
||||||
|
|
||||||
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
|
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
|
||||||
|
|
||||||
await waitJobs([ ...servers, serverHLSOnly ])
|
await waitJobs([ ...servers, serverHLSOnly ])
|
||||||
|
@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
|
||||||
|
|
||||||
describe('Video comments feed', function () {
|
describe('Video comments feed', function () {
|
||||||
|
|
||||||
it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () {
|
it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
|
const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue