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