aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/videos/import.ts1
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/api/videos/live.ts7
-rw-r--r--server/controllers/api/videos/passwords.ts105
-rw-r--r--server/controllers/api/videos/token.ts16
-rw-r--r--server/controllers/api/videos/update.ts16
-rw-r--r--server/controllers/api/videos/upload.ts7
-rw-r--r--server/helpers/custom-validators/videos.ts57
-rw-r--r--server/initializers/constants.ts10
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/initializers/migrations/0785-video-password-protection.ts31
-rw-r--r--server/lib/client-html.ts3
-rw-r--r--server/lib/video-pre-import.ts11
-rw-r--r--server/lib/video-privacy.ts10
-rw-r--r--server/lib/video-tokens-manager.ts25
-rw-r--r--server/middlewares/auth.ts15
-rw-r--r--server/middlewares/validators/shared/index.ts1
-rw-r--r--server/middlewares/validators/shared/video-passwords.ts80
-rw-r--r--server/middlewares/validators/shared/videos.ts85
-rw-r--r--server/middlewares/validators/sort.ts1
-rw-r--r--server/middlewares/validators/static.ts10
-rw-r--r--server/middlewares/validators/videos/index.ts2
-rw-r--r--server/middlewares/validators/videos/video-captions.ts5
-rw-r--r--server/middlewares/validators/videos/video-comments.ts7
-rw-r--r--server/middlewares/validators/videos/video-imports.ts12
-rw-r--r--server/middlewares/validators/videos/video-live.ts13
-rw-r--r--server/middlewares/validators/videos/video-passwords.ts77
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts2
-rw-r--r--server/middlewares/validators/videos/video-rates.ts3
-rw-r--r--server/middlewares/validators/videos/video-token.ts24
-rw-r--r--server/middlewares/validators/videos/videos.ts24
-rw-r--r--server/models/video/video-password.ts137
-rw-r--r--server/models/video/video-playlist-element.ts5
-rw-r--r--server/models/video/video.ts18
-rw-r--r--server/tests/api/check-params/live.ts4
-rw-r--r--server/tests/api/check-params/video-passwords.ts609
-rw-r--r--server/tests/api/check-params/video-token.ts44
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts132
-rw-r--r--server/tests/api/videos/video-passwords.ts97
-rw-r--r--server/tests/api/videos/video-playlists.ts17
-rw-r--r--server/tests/api/videos/video-static-file-privacy.ts127
-rw-r--r--server/tests/client.ts13
-rw-r--r--server/tests/feeds/feeds.ts9
-rw-r--r--server/types/express.d.ts3
-rw-r--r--server/types/models/video/index.ts1
-rw-r--r--server/types/models/video/video-password.ts3
-rw-r--r--server/types/models/video/video.ts4
47 files changed, 1797 insertions, 90 deletions
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 6a50aaf4e..b8016140e 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
120 videoChannel: res.locals.videoChannel, 120 videoChannel: res.locals.videoChannel,
121 tags: body.tags || undefined, 121 tags: body.tags || undefined,
122 user, 122 user,
123 videoPasswords: body.videoPasswords,
123 videoImportAttributes: { 124 videoImportAttributes: {
124 magnetUri, 125 magnetUri,
125 torrentName, 126 torrentName,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index a34325e79..d0eecf812 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -47,6 +47,7 @@ import { transcodingRouter } from './transcoding'
47import { updateRouter } from './update' 47import { updateRouter } from './update'
48import { uploadRouter } from './upload' 48import { uploadRouter } from './upload'
49import { viewRouter } from './view' 49import { viewRouter } from './view'
50import { videoPasswordRouter } from './passwords'
50 51
51const auditLogger = auditLoggerFactory('videos') 52const auditLogger = auditLoggerFactory('videos')
52const videosRouter = express.Router() 53const videosRouter = express.Router()
@@ -68,6 +69,7 @@ videosRouter.use('/', updateRouter)
68videosRouter.use('/', filesRouter) 69videosRouter.use('/', filesRouter)
69videosRouter.use('/', transcodingRouter) 70videosRouter.use('/', transcodingRouter)
70videosRouter.use('/', tokenRouter) 71videosRouter.use('/', tokenRouter)
72videosRouter.use('/', videoPasswordRouter)
71 73
72videosRouter.get('/categories', 74videosRouter.get('/categories',
73 openapiOperationDoc({ operationId: 'getCategories' }), 75 openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index de047d4ec..cf82c9791 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 18import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' 19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
20import { buildUUID, uuidToShort } from '@shared/extra-utils' 20import { buildUUID, uuidToShort } from '@shared/extra-utils'
21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' 21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { sequelizeTypescript } from '../../../initializers/database' 23import { sequelizeTypescript } from '../../../initializers/database'
24import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' 24import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' 25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video' 26import { VideoModel } from '../../../models/video/video'
27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' 27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
28import { VideoPasswordModel } from '@server/models/video/video-password'
28 29
29const liveRouter = express.Router() 30const liveRouter = express.Router()
30 31
@@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
202 203
203 await federateVideoIfNeeded(videoCreated, true, t) 204 await federateVideoIfNeeded(videoCreated, true, t)
204 205
206 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
207 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
208 }
209
205 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) 210 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
206 211
207 return { videoCreated } 212 return { videoCreated }
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts
new file mode 100644
index 000000000..d11cf5bcc
--- /dev/null
+++ b/server/controllers/api/videos/passwords.ts
@@ -0,0 +1,105 @@
1import express from 'express'
2
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { getFormattedObjects } from '../../../helpers/utils'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../../middlewares'
12import {
13 listVideoPasswordValidator,
14 paginationValidator,
15 removeVideoPasswordValidator,
16 updateVideoPasswordListValidator,
17 videoPasswordsSortValidator
18} from '../../../middlewares/validators'
19import { VideoPasswordModel } from '@server/models/video/video-password'
20import { logger, loggerTagsFactory } from '@server/helpers/logger'
21import { Transaction } from 'sequelize'
22import { getVideoWithAttributes } from '@server/helpers/video'
23
24const lTags = loggerTagsFactory('api', 'video')
25const videoPasswordRouter = express.Router()
26
27videoPasswordRouter.get('/:videoId/passwords',
28 authenticate,
29 paginationValidator,
30 videoPasswordsSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 asyncMiddleware(listVideoPasswordValidator),
34 asyncMiddleware(listVideoPasswords)
35)
36
37videoPasswordRouter.put('/:videoId/passwords',
38 authenticate,
39 asyncMiddleware(updateVideoPasswordListValidator),
40 asyncMiddleware(updateVideoPasswordList)
41)
42
43videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
44 authenticate,
45 asyncMiddleware(removeVideoPasswordValidator),
46 asyncRetryTransactionMiddleware(removeVideoPassword)
47)
48
49// ---------------------------------------------------------------------------
50
51export {
52 videoPasswordRouter
53}
54
55// ---------------------------------------------------------------------------
56
57async function listVideoPasswords (req: express.Request, res: express.Response) {
58 const options = {
59 videoId: res.locals.videoAll.id,
60 start: req.query.start,
61 count: req.query.count,
62 sort: req.query.sort
63 }
64
65 const resultList = await VideoPasswordModel.listPasswords(options)
66
67 return res.json(getFormattedObjects(resultList.data, resultList.total))
68}
69
70async function updateVideoPasswordList (req: express.Request, res: express.Response) {
71 const videoInstance = getVideoWithAttributes(res)
72 const videoId = videoInstance.id
73
74 const passwordArray = req.body.passwords as string[]
75
76 await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
77 await VideoPasswordModel.deleteAllPasswords(videoId, t)
78 await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
79 })
80
81 logger.info(
82 `Video passwords for video with name %s and uuid %s have been updated`,
83 videoInstance.name,
84 videoInstance.uuid,
85 lTags(videoInstance.uuid)
86 )
87
88 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
89}
90
91async function removeVideoPassword (req: express.Request, res: express.Response) {
92 const videoInstance = getVideoWithAttributes(res)
93 const password = res.locals.videoPassword
94
95 await VideoPasswordModel.deletePassword(password.id)
96 logger.info(
97 'Password with id %d of video named %s and uuid %s has been deleted.',
98 password.id,
99 videoInstance.name,
100 videoInstance.uuid,
101 lTags(videoInstance.uuid)
102 )
103
104 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
105}
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 22387c3e8..e961ffd9e 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -1,13 +1,14 @@
1import express from 'express' 1import express from 'express'
2import { VideoTokensManager } from '@server/lib/video-tokens-manager' 2import { VideoTokensManager } from '@server/lib/video-tokens-manager'
3import { VideoToken } from '@shared/models' 3import { VideoPrivacy, VideoToken } from '@shared/models'
4import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' 4import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
5 5
6const tokenRouter = express.Router() 6const tokenRouter = express.Router()
7 7
8tokenRouter.post('/:id/token', 8tokenRouter.post('/:id/token',
9 authenticate, 9 optionalAuthenticate,
10 asyncMiddleware(videosCustomGetValidator('only-video')), 10 asyncMiddleware(videosCustomGetValidator('only-video')),
11 videoFileTokenValidator,
11 generateToken 12 generateToken
12) 13)
13 14
@@ -22,12 +23,11 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 23function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 24 const video = res.locals.onlyVideo
24 25
25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) 26 const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
27 ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
28 : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 29
27 return res.json({ 30 return res.json({
28 files: { 31 files
29 token,
30 expires
31 }
32 } as VideoToken) 32 } as VideoToken)
33} 33}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index ddab428d4..28ec2cf37 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,13 +2,12 @@ import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { VideoPathManager } from '@server/lib/video-path-manager'
6import { setVideoPrivacy } from '@server/lib/video-privacy' 5import { setVideoPrivacy } from '@server/lib/video-privacy'
7import { openapiOperationDoc } from '@server/middlewares/doc' 6import { openapiOperationDoc } from '@server/middlewares/doc'
8import { FilteredModelAttributes } from '@server/types' 7import { FilteredModelAttributes } from '@server/types'
9import { MVideoFullLight } from '@server/types/models' 8import { MVideoFullLight } from '@server/types/models'
10import { forceNumber } from '@shared/core-utils' 9import { forceNumber } from '@shared/core-utils'
11import { HttpStatusCode, VideoUpdate } from '@shared/models' 10import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
12import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 11import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
13import { resetSequelizeInstance } from '../../../helpers/database-utils' 12import { resetSequelizeInstance } from '../../../helpers/database-utils'
14import { createReqFiles } from '../../../helpers/express-utils' 13import { createReqFiles } from '../../../helpers/express-utils'
@@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' 19import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 20import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
22import { VideoModel } from '../../../models/video/video' 21import { VideoModel } from '../../../models/video/video'
22import { VideoPathManager } from '@server/lib/video-path-manager'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24const lTags = loggerTagsFactory('api', 'video') 26const lTags = loggerTagsFactory('api', 'video')
25const auditLogger = auditLoggerFactory('videos') 27const auditLogger = auditLoggerFactory('videos')
@@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
176 const newPrivacy = forceNumber(videoInfoToUpdate.privacy) 178 const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
177 setVideoPrivacy(videoInstance, newPrivacy) 179 setVideoPrivacy(videoInstance, newPrivacy)
178 180
181 // Delete passwords if video is not anymore password protected
182 if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
183 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
184 }
185
186 if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
187 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
188 await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
189 }
190
179 // Unfederate the video if the new privacy is not compatible with federation 191 // Unfederate the video if the new privacy is not compatible with federation
180 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { 192 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
181 await VideoModel.sendDelete(videoInstance, { transaction }) 193 await VideoModel.sendDelete(videoInstance, { transaction })
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 885ac8b81..073eb480f 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -14,7 +14,7 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoSourceModel } from '@server/models/video/video-source' 14import { VideoSourceModel } from '@server/models/video/video-source'
15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' 15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils' 16import { uuidToShort } from '@shared/extra-utils'
17import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models' 17import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
19import { createReqFiles } from '../../../helpers/express-utils' 19import { createReqFiles } from '../../../helpers/express-utils'
20import { logger, loggerTagsFactory } from '../../../helpers/logger' 20import { logger, loggerTagsFactory } from '../../../helpers/logger'
@@ -33,6 +33,7 @@ import {
33} from '../../../middlewares' 33} from '../../../middlewares'
34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
35import { VideoModel } from '../../../models/video/video' 35import { VideoModel } from '../../../models/video/video'
36import { VideoPasswordModel } from '@server/models/video/video-password'
36 37
37const lTags = loggerTagsFactory('api', 'video') 38const lTags = loggerTagsFactory('api', 'video')
38const auditLogger = auditLoggerFactory('videos') 39const auditLogger = auditLoggerFactory('videos')
@@ -195,6 +196,10 @@ async function addVideo (options: {
195 transaction: t 196 transaction: t
196 }) 197 })
197 198
199 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
200 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
201 }
202
198 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) 203 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
199 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) 204 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
200 205
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 5f75ec27c..91109217c 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,7 +1,7 @@
1import { UploadFilesForCheck } from 'express' 1import { Response, Request, UploadFilesForCheck } from 'express'
2import { decode as magnetUriDecode } from 'magnet-uri' 2import { decode as magnetUriDecode } from 'magnet-uri'
3import validator from 'validator' 3import validator from 'validator'
4import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' 4import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
5import { 5import {
6 CONSTRAINTS_FIELDS, 6 CONSTRAINTS_FIELDS,
7 MIMETYPES, 7 MIMETYPES,
@@ -13,6 +13,7 @@ import {
13 VIDEO_STATES 13 VIDEO_STATES
14} from '../../initializers/constants' 14} from '../../initializers/constants'
15import { exists, isArray, isDateValid, isFileValid } from './misc' 15import { exists, isArray, isDateValid, isFileValid } from './misc'
16import { getVideoWithAttributes } from '@server/helpers/video'
16 17
17const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
18 19
@@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
110 return VIDEO_PRIVACIES[value] !== undefined 111 return VIDEO_PRIVACIES[value] !== undefined
111} 112}
112 113
114function isVideoReplayPrivacyValid (value: number) {
115 return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
116}
117
113function isScheduleVideoUpdatePrivacyValid (value: number) { 118function isScheduleVideoUpdatePrivacyValid (value: number) {
114 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL 119 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
115} 120}
@@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
141 return parsed && isVideoFileInfoHashValid(parsed.infoHash) 146 return parsed && isVideoFileInfoHashValid(parsed.infoHash)
142} 147}
143 148
149function isPasswordValid (password: string) {
150 return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min &&
151 password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max
152}
153
154function isValidPasswordProtectedPrivacy (req: Request, res: Response) {
155 const fail = (message: string) => {
156 res.fail({
157 status: HttpStatusCode.BAD_REQUEST_400,
158 message
159 })
160 return false
161 }
162
163 let privacy: VideoPrivacy
164 const video = getVideoWithAttributes(res)
165
166 if (exists(req.body?.privacy)) privacy = req.body.privacy
167 else if (exists(video?.privacy)) privacy = video.privacy
168
169 if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true
170
171 if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.')
172
173 const passwords = req.body.videoPasswords || req.body.passwords
174
175 if (passwords.length === 0) return fail('At least one video password is required.')
176
177 if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.')
178
179 for (const password of passwords) {
180 if (typeof password !== 'string') {
181 return fail('Video password should be a string.')
182 }
183
184 if (!isPasswordValid(password)) {
185 return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.')
186 }
187 }
188
189 return true
190}
191
144// --------------------------------------------------------------------------- 192// ---------------------------------------------------------------------------
145 193
146export { 194export {
@@ -164,9 +212,12 @@ export {
164 isVideoDurationValid, 212 isVideoDurationValid,
165 isVideoTagValid, 213 isVideoTagValid,
166 isVideoPrivacyValid, 214 isVideoPrivacyValid,
215 isVideoReplayPrivacyValid,
167 isVideoFileResolutionValid, 216 isVideoFileResolutionValid,
168 isVideoFileSizeValid, 217 isVideoFileSizeValid,
169 isVideoImageValid, 218 isVideoImageValid,
170 isVideoSupportValid, 219 isVideoSupportValid,
171 isVideoFilterValid 220 isVideoFilterValid,
221 isPasswordValid,
222 isValidPasswordProtectedPrivacy
172} 223}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index a92fd22d6..e2f34fe16 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
30const LAST_MIGRATION_VERSION = 780 30const LAST_MIGRATION_VERSION = 785
31 31
32// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
33 33
@@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = {
76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], 76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
77 VIDEO_COMMENTS: [ 'createdAt' ], 77 VIDEO_COMMENTS: [ 'createdAt' ],
78 78
79 VIDEO_PASSWORDS: [ 'createdAt' ],
80
79 VIDEO_RATES: [ 'createdAt' ], 81 VIDEO_RATES: [ 'createdAt' ],
80 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], 82 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
81 83
@@ -444,6 +446,9 @@ const CONSTRAINTS_FIELDS = {
444 REASON: { min: 1, max: 5000 }, // Length 446 REASON: { min: 1, max: 5000 }, // Length
445 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length 447 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
446 PROGRESS: { min: 0, max: 100 } // Value 448 PROGRESS: { min: 0, max: 100 } // Value
449 },
450 VIDEO_PASSWORD: {
451 LENGTH: { min: 2, max: 100 }
447 } 452 }
448} 453}
449 454
@@ -520,7 +525,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
520 [VideoPrivacy.PUBLIC]: 'Public', 525 [VideoPrivacy.PUBLIC]: 'Public',
521 [VideoPrivacy.UNLISTED]: 'Unlisted', 526 [VideoPrivacy.UNLISTED]: 'Unlisted',
522 [VideoPrivacy.PRIVATE]: 'Private', 527 [VideoPrivacy.PRIVATE]: 'Private',
523 [VideoPrivacy.INTERNAL]: 'Internal' 528 [VideoPrivacy.INTERNAL]: 'Internal',
529 [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
524} 530}
525 531
526const VIDEO_STATES: { [ id in VideoState ]: string } = { 532const VIDEO_STATES: { [ id in VideoState ]: string } = {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 14dd8c379..9e926c26c 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -56,6 +56,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
56import { VideoTagModel } from '../models/video/video-tag' 56import { VideoTagModel } from '../models/video/video-tag'
57import { VideoViewModel } from '../models/view/video-view' 57import { VideoViewModel } from '../models/view/video-view'
58import { CONFIG } from './config' 58import { CONFIG } from './config'
59import { VideoPasswordModel } from '@server/models/video/video-password'
59 60
60require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 61require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
61 62
@@ -163,6 +164,7 @@ async function initDatabaseModels (silent: boolean) {
163 VideoJobInfoModel, 164 VideoJobInfoModel,
164 VideoChannelSyncModel, 165 VideoChannelSyncModel,
165 UserRegistrationModel, 166 UserRegistrationModel,
167 VideoPasswordModel,
166 RunnerRegistrationTokenModel, 168 RunnerRegistrationTokenModel,
167 RunnerModel, 169 RunnerModel,
168 RunnerJobModel 170 RunnerJobModel
diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts
new file mode 100644
index 000000000..1d85f4489
--- /dev/null
+++ b/server/initializers/migrations/0785-video-password-protection.ts
@@ -0,0 +1,31 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 {
9 const query = `
10 CREATE TABLE IF NOT EXISTS "videoPassword" (
11 "id" SERIAL,
12 "password" VARCHAR(255) NOT NULL,
13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
16 PRIMARY KEY ("id")
17 );
18 `
19
20 await utils.sequelize.query(query, { transaction : utils.transaction })
21 }
22}
23
24function down (options) {
25 throw new Error('Not implemented.')
26}
27
28export {
29 up,
30 down
31}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 18b16bee1..be6df1792 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity'
32import { getBiggestActorImage } from './actor-image' 32import { getBiggestActorImage } from './actor-image'
33import { Hooks } from './plugins/hooks' 33import { Hooks } from './plugins/hooks'
34import { ServerConfigManager } from './server-config-manager' 34import { ServerConfigManager } from './server-config-manager'
35import { isVideoInPrivateDirectory } from './video-privacy'
35 36
36type Tags = { 37type Tags = {
37 ogType: string 38 ogType: string
@@ -106,7 +107,7 @@ class ClientHtml {
106 ]) 107 ])
107 108
108 // Let Angular application handle errors 109 // Let Angular application handle errors
109 if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { 110 if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
110 res.status(HttpStatusCode.NOT_FOUND_404) 111 res.status(HttpStatusCode.NOT_FOUND_404)
111 return html 112 return html
112 } 113 }
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
index df67dc953..0ac667ba3 100644
--- a/server/lib/video-pre-import.ts
+++ b/server/lib/video-pre-import.ts
@@ -30,6 +30,7 @@ import {
30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' 30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
31import { getLocalVideoActivityPubUrl } from './activitypub/url' 31import { getLocalVideoActivityPubUrl } from './activitypub/url'
32import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' 32import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail'
33import { VideoPasswordModel } from '@server/models/video/video-password'
33 34
34class YoutubeDlImportError extends Error { 35class YoutubeDlImportError extends Error {
35 code: YoutubeDlImportError.CODE 36 code: YoutubeDlImportError.CODE
@@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
64 tags: string[] 65 tags: string[]
65 videoImportAttributes: FilteredModelAttributes<VideoImportModel> 66 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
66 user: MUser 67 user: MUser
68 videoPasswords?: string[]
67}): Promise<MVideoImportFormattable> { 69}): Promise<MVideoImportFormattable> {
68 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters 70 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
69 71
70 const videoImport = await sequelizeTypescript.transaction(async t => { 72 const videoImport = await sequelizeTypescript.transaction(async t => {
71 const sequelizeOptions = { transaction: t } 73 const sequelizeOptions = { transaction: t }
@@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
77 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) 79 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
78 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) 80 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
79 81
82 if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
83 await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
84 }
85
80 await autoBlacklistVideoIfNeeded({ 86 await autoBlacklistVideoIfNeeded({
81 video: videoCreated, 87 video: videoCreated,
82 user, 88 user,
@@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
208 state: VideoImportState.PENDING, 214 state: VideoImportState.PENDING,
209 userId: user.id, 215 userId: user.id,
210 videoChannelSyncId: channelSync?.id 216 videoChannelSyncId: channelSync?.id
211 } 217 },
218 videoPasswords: importDataOverride.videoPasswords
212 }) 219 })
213 220
214 // Get video subtitles 221 // Get video subtitles
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts
index 41f9d62b3..39430ef1e 100644
--- a/server/lib/video-privacy.ts
+++ b/server/lib/video-privacy.ts
@@ -6,6 +6,12 @@ import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { VideoPrivacy, VideoStorage } from '@shared/models' 6import { VideoPrivacy, VideoStorage } from '@shared/models'
7import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' 7import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage'
8 8
9const validPrivacySet = new Set([
10 VideoPrivacy.PRIVATE,
11 VideoPrivacy.INTERNAL,
12 VideoPrivacy.PASSWORD_PROTECTED
13])
14
9function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { 15function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
10 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { 16 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
11 video.publishedAt = new Date() 17 video.publishedAt = new Date()
@@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
14 video.privacy = newPrivacy 20 video.privacy = newPrivacy
15} 21}
16 22
17function isVideoInPrivateDirectory (privacy: VideoPrivacy) { 23function isVideoInPrivateDirectory (privacy) {
18 return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL 24 return validPrivacySet.has(privacy)
19} 25}
20 26
21function isVideoInPublicDirectory (privacy: VideoPrivacy) { 27function isVideoInPublicDirectory (privacy: VideoPrivacy) {
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index 660533528..e28e55cf7 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -12,26 +12,34 @@ class VideoTokensManager {
12 12
13 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
14 14
15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
18 }) 18 })
19 19
20 private constructor () {} 20 private constructor () {}
21 21
22 create (options: { 22 createForAuthUser (options: {
23 user: MUserAccountUrl 23 user: MUserAccountUrl
24 videoUUID: string 24 videoUUID: string
25 }) { 25 }) {
26 const token = buildUUID() 26 const { token, expires } = this.generateVideoToken()
27
28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
29 27
30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) 28 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
31 29
32 return { token, expires } 30 return { token, expires }
33 } 31 }
34 32
33 createForPasswordProtectedVideo (options: {
34 videoUUID: string
35 }) {
36 const { token, expires } = this.generateVideoToken()
37
38 this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
39
40 return { token, expires }
41 }
42
35 hasToken (options: { 43 hasToken (options: {
36 token: string 44 token: string
37 videoUUID: string 45 videoUUID: string
@@ -54,6 +62,13 @@ class VideoTokensManager {
54 static get Instance () { 62 static get Instance () {
55 return this.instance || (this.instance = new this()) 63 return this.instance || (this.instance = new this())
56 } 64 }
65
66 private generateVideoToken () {
67 const token = buildUUID()
68 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
69
70 return { token, expires }
71 }
57} 72}
58 73
59// --------------------------------------------------------------------------- 74// ---------------------------------------------------------------------------
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts
index 0eefa2a8e..39a7b2998 100644
--- a/server/middlewares/auth.ts
+++ b/server/middlewares/auth.ts
@@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner'
5import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
6import { logger } from '../helpers/logger' 6import { logger } from '../helpers/logger'
7import { handleOAuthAuthenticate } from '../lib/auth/oauth' 7import { handleOAuthAuthenticate } from '../lib/auth/oauth'
8import { ServerErrorCode } from '@shared/models'
8 9
9function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 10function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
10 handleOAuthAuthenticate(req, res) 11 handleOAuthAuthenticate(req, res)
@@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
48 .catch(err => logger.error('Cannot get access token.', { err })) 49 .catch(err => logger.error('Cannot get access token.', { err }))
49} 50}
50 51
51function authenticatePromise (req: express.Request, res: express.Response) { 52function authenticatePromise (options: {
53 req: express.Request
54 res: express.Response
55 errorMessage?: string
56 errorStatus?: HttpStatusCode
57 errorType?: ServerErrorCode
58}) {
59 const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
52 return new Promise<void>(resolve => { 60 return new Promise<void>(resolve => {
53 // Already authenticated? (or tried to) 61 // Already authenticated? (or tried to)
54 if (res.locals.oauth?.token.User) return resolve() 62 if (res.locals.oauth?.token.User) return resolve()
55 63
56 if (res.locals.authenticated === false) { 64 if (res.locals.authenticated === false) {
57 return res.fail({ 65 return res.fail({
58 status: HttpStatusCode.UNAUTHORIZED_401, 66 status: errorStatus,
59 message: 'Not authenticated' 67 type: errorType,
68 message: errorMessage
60 }) 69 })
61 } 70 }
62 71
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index de98cd442..e5cff2dda 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -10,4 +10,5 @@ export * from './video-comments'
10export * from './video-imports' 10export * from './video-imports'
11export * from './video-ownerships' 11export * from './video-ownerships'
12export * from './video-playlists' 12export * from './video-playlists'
13export * from './video-passwords'
13export * from './videos' 14export * from './videos'
diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts
new file mode 100644
index 000000000..efcc95dc4
--- /dev/null
+++ b/server/middlewares/validators/shared/video-passwords.ts
@@ -0,0 +1,80 @@
1import express from 'express'
2import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models'
3import { forceNumber } from '@shared/core-utils'
4import { VideoPasswordModel } from '@server/models/video/video-password'
5import { header } from 'express-validator'
6import { getVideoWithAttributes } from '@server/helpers/video'
7
8function isValidVideoPasswordHeader () {
9 return header('x-peertube-video-password')
10 .optional()
11 .isString()
12}
13
14function checkVideoIsPasswordProtected (res: express.Response) {
15 const video = getVideoWithAttributes(res)
16 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
17 res.fail({
18 status: HttpStatusCode.BAD_REQUEST_400,
19 message: 'Video is not password protected'
20 })
21 return false
22 }
23
24 return true
25}
26
27async function doesVideoPasswordExist (idArg: number | string, res: express.Response) {
28 const video = getVideoWithAttributes(res)
29 const id = forceNumber(idArg)
30 const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
31
32 if (!videoPassword) {
33 res.fail({
34 status: HttpStatusCode.NOT_FOUND_404,
35 message: 'Video password not found'
36 })
37 return false
38 }
39
40 res.locals.videoPassword = videoPassword
41
42 return true
43}
44
45async function isVideoPasswordDeletable (res: express.Response) {
46 const user = res.locals.oauth.token.User
47 const userAccount = user.Account
48 const video = res.locals.videoAll
49
50 // Check if the user who did the request is able to delete the video passwords
51 if (
52 user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator
53 video.VideoChannel.accountId !== userAccount.id // Not the video owner
54 ) {
55 res.fail({
56 status: HttpStatusCode.FORBIDDEN_403,
57 message: 'Cannot remove passwords of another user\'s video'
58 })
59 return false
60 }
61
62 const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
63
64 if (passwordCount <= 1) {
65 res.fail({
66 status: HttpStatusCode.BAD_REQUEST_400,
67 message: 'Cannot delete the last password of the protected video'
68 })
69 return false
70 }
71
72 return true
73}
74
75export {
76 isValidVideoPasswordHeader,
77 checkVideoIsPasswordProtected as isVideoPasswordProtected,
78 doesVideoPasswordExist,
79 isVideoPasswordDeletable
80}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index 0033a32ff..9a7497007 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -20,6 +20,8 @@ import {
20 MVideoWithRights 20 MVideoWithRights
21} from '@server/types/models' 21} from '@server/types/models'
22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' 22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { 26async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
25 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 27 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: {
111}) { 113}) {
112 const { req, res, video, paramId } = options 114 const { req, res, video, paramId } = options
113 115
114 if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { 116 if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
115 return checkCanSeeAuthVideo(req, res, video) 117 return checkCanSeeUserAuthVideo({ req, res, video })
118 }
119
120 if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
121 return checkCanSeePasswordProtectedVideo({ req, res, video })
116 } 122 }
117 123
118 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { 124 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: {
122 throw new Error('Unknown video privacy when checking video right ' + video.url) 128 throw new Error('Unknown video privacy when checking video right ' + video.url)
123} 129}
124 130
125async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { 131async function checkCanSeeUserAuthVideo (options: {
132 req: Request
133 res: Response
134 video: MVideoId | MVideoWithRights
135}) {
136 const { req, res, video } = options
137
126 const fail = () => { 138 const fail = () => {
127 res.fail({ 139 res.fail({
128 status: HttpStatusCode.FORBIDDEN_403, 140 status: HttpStatusCode.FORBIDDEN_403,
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
132 return false 144 return false
133 } 145 }
134 146
135 await authenticatePromise(req, res) 147 await authenticatePromise({ req, res })
136 148
137 const user = res.locals.oauth?.token.User 149 const user = res.locals.oauth?.token.User
138 if (!user) return fail() 150 if (!user) return fail()
139 151
140 const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId 152 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
141 ? video as MVideoWithRights
142 : await VideoModel.loadFull(video.id)
143 153
144 const privacy = videoWithRights.privacy 154 const privacy = videoWithRights.privacy
145 155
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
148 return true 158 return true
149 } 159 }
150 160
151 const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
152
153 if (videoWithRights.isBlacklisted()) { 161 if (videoWithRights.isBlacklisted()) {
154 if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true 162 if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true
155 163
156 return fail() 164 return fail()
157 } 165 }
158 166
159 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { 167 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
160 if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true 168 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
161 169
162 return fail() 170 return fail()
163 } 171 }
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
166 return fail() 174 return fail()
167} 175}
168 176
177async function checkCanSeePasswordProtectedVideo (options: {
178 req: Request
179 res: Response
180 video: MVideo
181}) {
182 const { req, res, video } = options
183
184 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
185
186 const videoPassword = req.header('x-peertube-video-password')
187
188 if (!exists(videoPassword)) {
189 const errorMessage = 'Please provide a password to access this password protected video'
190 const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
191
192 if (req.header('authorization')) {
193 await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType })
194 const user = res.locals.oauth?.token.User
195
196 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
197 }
198
199 res.fail({
200 status: HttpStatusCode.FORBIDDEN_403,
201 type: errorType,
202 message: errorMessage
203 })
204 return false
205 }
206
207 if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
208
209 res.fail({
210 status: HttpStatusCode.FORBIDDEN_403,
211 type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
212 message: 'Incorrect video password. Access to the video is denied.'
213 })
214
215 return false
216}
217
218function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) {
219 const isOwnedByUser = video.VideoChannel.Account.userId === user.id
220
221 return isOwnedByUser || user.hasRight(right)
222}
223
224async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
225 return video.VideoChannel?.Account?.userId
226 ? video
227 : VideoModel.loadFull(video.id)
228}
229
169// --------------------------------------------------------------------------- 230// ---------------------------------------------------------------------------
170 231
171async function checkCanAccessVideoStaticFiles (options: { 232async function checkCanAccessVideoStaticFiles (options: {
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
176}) { 237}) {
177 const { video, req, res } = options 238 const { video, req, res } = options
178 239
179 if (res.locals.oauth?.token.User) { 240 if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) {
180 return checkCanSeeVideo(options) 241 return checkCanSeeVideo(options)
181 } 242 }
182 243
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 959f663ac..07d6cba82 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) 28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) 29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) 30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
31 32
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) 33export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) 34export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts
index 9c2d890ba..36a94080c 100644
--- a/server/middlewares/validators/static.ts
+++ b/server/middlewares/validators/static.ts
@@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video'
9import { VideoFileModel } from '@server/models/video/video-file' 9import { VideoFileModel } from '@server/models/video/video-file'
10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' 10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
11import { HttpStatusCode } from '@shared/models' 11import { HttpStatusCode } from '@shared/models'
12import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' 12import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
13 13
14type LRUValue = { 14type LRUValue = {
15 allowed: boolean 15 allowed: boolean
@@ -25,6 +25,8 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({
25const ensureCanAccessVideoPrivateWebTorrentFiles = [ 25const ensureCanAccessVideoPrivateWebTorrentFiles = [
26 query('videoFileToken').optional().custom(exists), 26 query('videoFileToken').optional().custom(exists),
27 27
28 isValidVideoPasswordHeader(),
29
28 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 30 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
29 if (areValidationErrors(req, res)) return 31 if (areValidationErrors(req, res)) return
30 32
@@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
73 .optional() 75 .optional()
74 .customSanitizer(isSafePeerTubeFilenameWithoutExtension), 76 .customSanitizer(isSafePeerTubeFilenameWithoutExtension),
75 77
78 isValidVideoPasswordHeader(),
79
76 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
77 if (areValidationErrors(req, res)) return 81 if (areValidationErrors(req, res)) return
78 82
@@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU
167} 171}
168 172
169function extractTokenOrDie (req: express.Request, res: express.Response) { 173function extractTokenOrDie (req: express.Request, res: express.Response) {
170 const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken 174 const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
171 175
172 if (!token) { 176 if (!token) {
173 return res.fail({ 177 return res.fail({
174 message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', 178 message: 'Video password header, video file token query parameter and bearer token are all missing', //
175 status: HttpStatusCode.FORBIDDEN_403 179 status: HttpStatusCode.FORBIDDEN_403
176 }) 180 })
177 } 181 }
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index d225dfe45..0c824c314 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -12,6 +12,8 @@ export * from './video-shares'
12export * from './video-source' 12export * from './video-source'
13export * from './video-stats' 13export * from './video-stats'
14export * from './video-studio' 14export * from './video-studio'
15export * from './video-token'
15export * from './video-transcoding' 16export * from './video-transcoding'
16export * from './videos' 17export * from './videos'
17export * from './video-channel-sync' 18export * from './video-channel-sync'
19export * from './video-passwords'
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 72b2febc3..077a58d2e 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -10,7 +10,8 @@ import {
10 checkUserCanManageVideo, 10 checkUserCanManageVideo,
11 doesVideoCaptionExist, 11 doesVideoCaptionExist,
12 doesVideoExist, 12 doesVideoExist,
13 isValidVideoIdParam 13 isValidVideoIdParam,
14 isValidVideoPasswordHeader
14} from '../shared' 15} from '../shared'
15 16
16const addVideoCaptionValidator = [ 17const addVideoCaptionValidator = [
@@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
62const listVideoCaptionsValidator = [ 63const listVideoCaptionsValidator = [
63 isValidVideoIdParam('videoId'), 64 isValidVideoIdParam('videoId'),
64 65
66 isValidVideoPasswordHeader(),
67
65 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 68 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
66 if (areValidationErrors(req, res)) return 69 if (areValidationErrors(req, res)) return
67 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 70 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 133feb7bd..70689b02e 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -14,7 +14,8 @@ import {
14 doesVideoCommentExist, 14 doesVideoCommentExist,
15 doesVideoCommentThreadExist, 15 doesVideoCommentThreadExist,
16 doesVideoExist, 16 doesVideoExist,
17 isValidVideoIdParam 17 isValidVideoIdParam,
18 isValidVideoPasswordHeader
18} from '../shared' 19} from '../shared'
19 20
20const listVideoCommentsValidator = [ 21const listVideoCommentsValidator = [
@@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
51 52
52const listVideoCommentThreadsValidator = [ 53const listVideoCommentThreadsValidator = [
53 isValidVideoIdParam('videoId'), 54 isValidVideoIdParam('videoId'),
55 isValidVideoPasswordHeader(),
54 56
55 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 57 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
56 if (areValidationErrors(req, res)) return 58 if (areValidationErrors(req, res)) return
@@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
67 69
68 param('threadId') 70 param('threadId')
69 .custom(isIdValid), 71 .custom(isIdValid),
72 isValidVideoPasswordHeader(),
70 73
71 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 if (areValidationErrors(req, res)) return 75 if (areValidationErrors(req, res)) return
@@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
84 87
85 body('text') 88 body('text')
86 .custom(isValidVideoCommentText), 89 .custom(isValidVideoCommentText),
90 isValidVideoPasswordHeader(),
87 91
88 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 92 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
89 if (areValidationErrors(req, res)) return 93 if (areValidationErrors(req, res)) return
@@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
102 isValidVideoIdParam('videoId'), 106 isValidVideoIdParam('videoId'),
103 107
104 param('commentId').custom(isIdValid), 108 param('commentId').custom(isIdValid),
109 isValidVideoPasswordHeader(),
105 110
106 body('text').custom(isValidVideoCommentText), 111 body('text').custom(isValidVideoCommentText),
107 112
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index 72442aeb6..a1cb65b70 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' 9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
12import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' 12import {
13 isValidPasswordProtectedPrivacy,
14 isVideoMagnetUriValid,
15 isVideoNameValid
16} from '../../../helpers/custom-validators/videos'
13import { cleanUpReqFiles } from '../../../helpers/express-utils' 17import { cleanUpReqFiles } from '../../../helpers/express-utils'
14import { logger } from '../../../helpers/logger' 18import { logger } from '../../../helpers/logger'
15import { CONFIG } from '../../../initializers/config' 19import { CONFIG } from '../../../initializers/config'
@@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
38 .custom(isVideoNameValid).withMessage( 42 .custom(isVideoNameValid).withMessage(
39 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` 43 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
40 ), 44 ),
45 body('videoPasswords')
46 .optional()
47 .isArray()
48 .withMessage('Video passwords should be an array.'),
41 49
42 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 50 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
43 const user = res.locals.oauth.token.User 51 const user = res.locals.oauth.token.User
@@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
45 53
46 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 54 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
47 55
56 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
57
48 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { 58 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
49 cleanUpReqFiles(req) 59 cleanUpReqFiles(req)
50 60
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 2aff831a8..ec69a3011 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -17,7 +17,7 @@ import {
17 VideoState 17 VideoState
18} from '@shared/models' 18} from '@shared/models'
19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
20import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos' 20import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos'
21import { cleanUpReqFiles } from '../../../helpers/express-utils' 21import { cleanUpReqFiles } from '../../../helpers/express-utils'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { CONFIG } from '../../../initializers/config' 23import { CONFIG } from '../../../initializers/config'
@@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
69 body('replaySettings.privacy') 69 body('replaySettings.privacy')
70 .optional() 70 .optional()
71 .customSanitizer(toIntOrNull) 71 .customSanitizer(toIntOrNull)
72 .custom(isVideoPrivacyValid), 72 .custom(isVideoReplayPrivacyValid),
73 73
74 body('permanentLive') 74 body('permanentLive')
75 .optional() 75 .optional()
@@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
81 .customSanitizer(toIntOrNull) 81 .customSanitizer(toIntOrNull)
82 .custom(isLiveLatencyModeValid), 82 .custom(isLiveLatencyModeValid),
83 83
84 body('videoPasswords')
85 .optional()
86 .isArray()
87 .withMessage('Video passwords should be an array.'),
88
84 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 89 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
85 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 90 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
86 91
92 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
93
87 if (CONFIG.LIVE.ENABLED !== true) { 94 if (CONFIG.LIVE.ENABLED !== true) {
88 cleanUpReqFiles(req) 95 cleanUpReqFiles(req)
89 96
@@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
170 body('replaySettings.privacy') 177 body('replaySettings.privacy')
171 .optional() 178 .optional()
172 .customSanitizer(toIntOrNull) 179 .customSanitizer(toIntOrNull)
173 .custom(isVideoPrivacyValid), 180 .custom(isVideoReplayPrivacyValid),
174 181
175 body('latencyMode') 182 body('latencyMode')
176 .optional() 183 .optional()
diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts
new file mode 100644
index 000000000..200e496f6
--- /dev/null
+++ b/server/middlewares/validators/videos/video-passwords.ts
@@ -0,0 +1,77 @@
1import express from 'express'
2import {
3 areValidationErrors,
4 doesVideoExist,
5 isVideoPasswordProtected,
6 isValidVideoIdParam,
7 doesVideoPasswordExist,
8 isVideoPasswordDeletable,
9 checkUserCanManageVideo
10} from '../shared'
11import { body, param } from 'express-validator'
12import { isIdValid } from '@server/helpers/custom-validators/misc'
13import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos'
14import { UserRight } from '@shared/models'
15
16const listVideoPasswordValidator = [
17 isValidVideoIdParam('videoId'),
18
19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
20 if (areValidationErrors(req, res)) return
21
22 if (!await doesVideoExist(req.params.videoId, res)) return
23 if (!isVideoPasswordProtected(res)) return
24
25 // Check if the user who did the request is able to access video password list
26 const user = res.locals.oauth.token.User
27 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return
28
29 return next()
30 }
31]
32
33const updateVideoPasswordListValidator = [
34 body('passwords')
35 .optional()
36 .isArray()
37 .withMessage('Video passwords should be an array.'),
38
39 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
40 if (areValidationErrors(req, res)) return
41
42 if (!await doesVideoExist(req.params.videoId, res)) return
43 if (!isValidPasswordProtectedPrivacy(req, res)) return
44
45 // Check if the user who did the request is able to update video passwords
46 const user = res.locals.oauth.token.User
47 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
48
49 return next()
50 }
51]
52
53const removeVideoPasswordValidator = [
54 isValidVideoIdParam('videoId'),
55
56 param('passwordId')
57 .custom(isIdValid),
58
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 if (areValidationErrors(req, res)) return
61
62 if (!await doesVideoExist(req.params.videoId, res)) return
63 if (!isVideoPasswordProtected(res)) return
64 if (!await doesVideoPasswordExist(req.params.passwordId, res)) return
65 if (!await isVideoPasswordDeletable(res)) return
66
67 return next()
68 }
69]
70
71// ---------------------------------------------------------------------------
72
73export {
74 listVideoPasswordValidator,
75 updateVideoPasswordListValidator,
76 removeVideoPasswordValidator
77}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index c631a16f8..95a5ba63a 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
153 } 153 }
154 154
155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { 155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
156 await authenticatePromise(req, res) 156 await authenticatePromise({ req, res })
157 157
158 const user = res.locals.oauth ? res.locals.oauth.token.User : null 158 const user = res.locals.oauth ? res.locals.oauth.token.User : null
159 159
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 275634d5b..c837b047b 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc'
7import { isRatingValid } from '../../../helpers/custom-validators/video-rates' 7import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' 8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
9import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 9import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' 10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared'
11 11
12const videoUpdateRateValidator = [ 12const videoUpdateRateValidator = [
13 isValidVideoIdParam('id'), 13 isValidVideoIdParam('id'),
14 14
15 body('rating') 15 body('rating')
16 .custom(isVideoRatingTypeValid), 16 .custom(isVideoRatingTypeValid),
17 isValidVideoPasswordHeader(),
17 18
18 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
19 if (areValidationErrors(req, res)) return 20 if (areValidationErrors(req, res)) return
diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts
new file mode 100644
index 000000000..d4253e21d
--- /dev/null
+++ b/server/middlewares/validators/videos/video-token.ts
@@ -0,0 +1,24 @@
1import express from 'express'
2import { VideoPrivacy } from '../../../../shared/models/videos'
3import { HttpStatusCode } from '@shared/models'
4import { exists } from '@server/helpers/custom-validators/misc'
5
6const videoFileTokenValidator = [
7 (req: express.Request, res: express.Response, next: express.NextFunction) => {
8 const video = res.locals.onlyVideo
9 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
10 return res.fail({
11 status: HttpStatusCode.UNAUTHORIZED_401,
12 message: 'Not authenticated'
13 })
14 }
15
16 return next()
17 }
18]
19
20// ---------------------------------------------------------------------------
21
22export {
23 videoFileTokenValidator
24}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 794e1d4f1..7f1f39b11 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -23,6 +23,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../
23import { 23import {
24 areVideoTagsValid, 24 areVideoTagsValid,
25 isScheduleVideoUpdatePrivacyValid, 25 isScheduleVideoUpdatePrivacyValid,
26 isValidPasswordProtectedPrivacy,
26 isVideoCategoryValid, 27 isVideoCategoryValid,
27 isVideoDescriptionValid, 28 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid, 29 isVideoFileMimeTypeValid,
@@ -55,7 +56,8 @@ import {
55 doesVideoChannelOfAccountExist, 56 doesVideoChannelOfAccountExist,
56 doesVideoExist, 57 doesVideoExist,
57 doesVideoFileOfVideoExist, 58 doesVideoFileOfVideoExist,
58 isValidVideoIdParam 59 isValidVideoIdParam,
60 isValidVideoPasswordHeader
59} from '../shared' 61} from '../shared'
60 62
61const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 63const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
@@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
70 body('channelId') 72 body('channelId')
71 .customSanitizer(toIntOrNull) 73 .customSanitizer(toIntOrNull)
72 .custom(isIdValid), 74 .custom(isIdValid),
75 body('videoPasswords')
76 .optional()
77 .isArray()
78 .withMessage('Video passwords should be an array.'),
73 79
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 81 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
@@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
81 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
82 } 88 }
83 89
90 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
91
84 try { 92 try {
85 if (!videoFile.duration) await addDurationToVideo(videoFile) 93 if (!videoFile.duration) await addDurationToVideo(videoFile)
86 } catch (err) { 94 } catch (err) {
@@ -174,6 +182,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
174 body('channelId') 182 body('channelId')
175 .customSanitizer(toIntOrNull) 183 .customSanitizer(toIntOrNull)
176 .custom(isIdValid), 184 .custom(isIdValid),
185 body('videoPasswords')
186 .optional()
187 .isArray()
188 .withMessage('Video passwords should be an array.'),
177 189
178 header('x-upload-content-length') 190 header('x-upload-content-length')
179 .isNumeric() 191 .isNumeric()
@@ -205,6 +217,8 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
205 const files = { videofile: [ videoFileMetadata ] } 217 const files = { videofile: [ videoFileMetadata ] }
206 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() 218 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
207 219
220 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
221
208 // multer required unsetting the Content-Type, now we can set it for node-uploadx 222 // multer required unsetting the Content-Type, now we can set it for node-uploadx
209 req.headers['content-type'] = 'application/json; charset=utf-8' 223 req.headers['content-type'] = 'application/json; charset=utf-8'
210 // place previewfile in metadata so that uploadx saves it in .META 224 // place previewfile in metadata so that uploadx saves it in .META
@@ -227,12 +241,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
227 .optional() 241 .optional()
228 .customSanitizer(toIntOrNull) 242 .customSanitizer(toIntOrNull)
229 .custom(isIdValid), 243 .custom(isIdValid),
244 body('videoPasswords')
245 .optional()
246 .isArray()
247 .withMessage('Video passwords should be an array.'),
230 248
231 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 249 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
232 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 250 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
233 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) 251 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
234 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) 252 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
235 253
254 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
255
236 const video = getVideoWithAttributes(res) 256 const video = getVideoWithAttributes(res)
237 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { 257 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
238 return res.fail({ message: 'Cannot update privacy of a live that has already started' }) 258 return res.fail({ message: 'Cannot update privacy of a live that has already started' })
@@ -281,6 +301,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
281 return [ 301 return [
282 isValidVideoIdParam('id'), 302 isValidVideoIdParam('id'),
283 303
304 isValidVideoPasswordHeader(),
305
284 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 306 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
285 if (areValidationErrors(req, res)) return 307 if (areValidationErrors(req, res)) return
286 if (!await doesVideoExist(req.params.id, res, fetchType)) return 308 if (!await doesVideoExist(req.params.id, res, fetchType)) return
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
new file mode 100644
index 000000000..648366c3b
--- /dev/null
+++ b/server/models/video/video-password.ts
@@ -0,0 +1,137 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from './video'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { ResultList, VideoPassword } from '@shared/models'
5import { getSort, throwIfNotValid } from '../shared'
6import { FindOptions, Transaction } from 'sequelize'
7import { MVideoPassword } from '@server/types/models'
8import { isPasswordValid } from '@server/helpers/custom-validators/videos'
9import { pick } from '@shared/core-utils'
10
11@DefaultScope(() => ({
12 include: [
13 {
14 model: VideoModel.unscoped(),
15 required: true
16 }
17 ]
18}))
19@Table({
20 tableName: 'videoPassword',
21 indexes: [
22 {
23 fields: [ 'videoId', 'password' ],
24 unique: true
25 }
26 ]
27})
28export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
29
30 @AllowNull(false)
31 @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
32 @Column
33 password: string
34
35 @CreatedAt
36 createdAt: Date
37
38 @UpdatedAt
39 updatedAt: Date
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Video: VideoModel
52
53 static async countByVideoId (videoId: number, t?: Transaction) {
54 const query: FindOptions = {
55 where: {
56 videoId
57 },
58 transaction: t
59 }
60
61 return VideoPasswordModel.count(query)
62 }
63
64 static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
65 const { id, videoId, t } = options
66 const query: FindOptions = {
67 where: {
68 id,
69 videoId
70 },
71 transaction: t
72 }
73
74 return VideoPasswordModel.findOne(query)
75 }
76
77 static async listPasswords (options: {
78 start: number
79 count: number
80 sort: string
81 videoId: number
82 }): Promise<ResultList<MVideoPassword>> {
83 const { start, count, sort, videoId } = options
84
85 const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
86 where: { videoId },
87 order: getSort(sort),
88 offset: start,
89 limit: count
90 })
91
92 return { total, data }
93 }
94
95 static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
96 for (const password of passwords) {
97 await VideoPasswordModel.create({
98 password,
99 videoId
100 }, { transaction })
101 }
102 }
103
104 static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
105 await VideoPasswordModel.destroy({
106 where: { videoId },
107 transaction
108 })
109 }
110
111 static async deletePassword (passwordId: number, transaction?: Transaction) {
112 await VideoPasswordModel.destroy({
113 where: { id: passwordId },
114 transaction
115 })
116 }
117
118 static async isACorrectPassword (options: {
119 videoId: number
120 password: string
121 }) {
122 const query = {
123 where: pick(options, [ 'videoId', 'password' ])
124 }
125 return VideoPasswordModel.findOne(query)
126 }
127
128 toFormattedJSON (): VideoPassword {
129 return {
130 id: this.id,
131 password: this.password,
132 videoId: this.videoId,
133 createdAt: this.createdAt,
134 updatedAt: this.updatedAt
135 }
136 }
137}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index b832f9768..61ae6b9fe 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
336 // Internal video? 336 // Internal video?
337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR 337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
338 338
339 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE 339 // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
340 if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
341 return VideoPlaylistElementType.PRIVATE
342 }
340 343
341 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE 344 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
342 345
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 8e3af62a4..f90f2b7f6 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -136,6 +136,7 @@ import { VideoFileModel } from './video-file'
136import { VideoImportModel } from './video-import' 136import { VideoImportModel } from './video-import'
137import { VideoJobInfoModel } from './video-job-info' 137import { VideoJobInfoModel } from './video-job-info'
138import { VideoLiveModel } from './video-live' 138import { VideoLiveModel } from './video-live'
139import { VideoPasswordModel } from './video-password'
139import { VideoPlaylistElementModel } from './video-playlist-element' 140import { VideoPlaylistElementModel } from './video-playlist-element'
140import { VideoShareModel } from './video-share' 141import { VideoShareModel } from './video-share'
141import { VideoSourceModel } from './video-source' 142import { VideoSourceModel } from './video-source'
@@ -734,6 +735,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
734 }) 735 })
735 VideoCaptions: VideoCaptionModel[] 736 VideoCaptions: VideoCaptionModel[]
736 737
738 @HasMany(() => VideoPasswordModel, {
739 foreignKey: {
740 name: 'videoId',
741 allowNull: false
742 },
743 onDelete: 'cascade'
744 })
745 VideoPasswords: VideoPasswordModel[]
746
737 @HasOne(() => VideoJobInfoModel, { 747 @HasOne(() => VideoJobInfoModel, {
738 foreignKey: { 748 foreignKey: {
739 name: 'videoId', 749 name: 'videoId',
@@ -1918,7 +1928,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1918 1928
1919 // --------------------------------------------------------------------------- 1929 // ---------------------------------------------------------------------------
1920 1930
1921 requiresAuth (options: { 1931 requiresUserAuth (options: {
1922 urlParamId: string 1932 urlParamId: string
1923 checkBlacklist: boolean 1933 checkBlacklist: boolean
1924 }) { 1934 }) {
@@ -1936,11 +1946,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1936 1946
1937 if (checkBlacklist && this.VideoBlacklist) return true 1947 if (checkBlacklist && this.VideoBlacklist) return true
1938 1948
1939 if (this.privacy !== VideoPrivacy.PUBLIC) { 1949 if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
1940 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) 1950 return false
1941 } 1951 }
1942 1952
1943 return false 1953 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1944 } 1954 }
1945 1955
1946 hasPrivateStaticPath () { 1956 hasPrivateStaticPath () {
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 2dc735c23..406a96824 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -143,7 +143,7 @@ describe('Test video lives API validator', function () {
143 }) 143 })
144 144
145 it('Should fail with a bad privacy for replay settings', async function () { 145 it('Should fail with a bad privacy for replay settings', async function () {
146 const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } } 146 const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } }
147 147
148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
149 }) 149 })
@@ -472,7 +472,7 @@ describe('Test video lives API validator', function () {
472 }) 472 })
473 473
474 it('Should fail with a bad privacy for replay settings', async function () { 474 it('Should fail with a bad privacy for replay settings', async function () {
475 const fields = { saveReplay: true, replaySettings: { privacy: 5 } } 475 const fields = { saveReplay: true, replaySettings: { privacy: 999 } }
476 476
477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
478 }) 478 })
diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts
new file mode 100644
index 000000000..4e936b5d2
--- /dev/null
+++ b/server/tests/api/check-params/video-passwords.ts
@@ -0,0 +1,609 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import {
3 FIXTURE_URLS,
4 checkBadCountPagination,
5 checkBadSortPagination,
6 checkBadStartPagination,
7 checkUploadVideoParam
8} from '@server/tests/shared'
9import { root } from '@shared/core-utils'
10import {
11 HttpStatusCode,
12 PeerTubeProblemDocument,
13 ServerErrorCode,
14 VideoCreateResult,
15 VideoPrivacy
16} from '@shared/models'
17import {
18 cleanupTests,
19 createSingleServer,
20 makePostBodyRequest,
21 PeerTubeServer,
22 setAccessTokensToServers
23} from '@shared/server-commands'
24import { expect } from 'chai'
25import { join } from 'path'
26
27describe('Test video passwords validator', function () {
28 let path: string
29 let server: PeerTubeServer
30 let userAccessToken = ''
31 let video: VideoCreateResult
32 let channelId: number
33 let publicVideo: VideoCreateResult
34 let commentId: number
35 // ---------------------------------------------------------------
36
37 before(async function () {
38 this.timeout(50000)
39
40 server = await createSingleServer(1)
41
42 await setAccessTokensToServers([ server ])
43
44 await server.config.updateCustomSubConfig({
45 newConfig: {
46 live: {
47 enabled: true,
48 latencySetting: {
49 enabled: false
50 },
51 allowReplay: false
52 },
53 import: {
54 videos: {
55 http:{
56 enabled: true
57 }
58 }
59 }
60 }
61 })
62
63 userAccessToken = await server.users.generateUserAndToken('user1')
64
65 {
66 const body = await server.users.getMyInfo()
67 channelId = body.videoChannels[0].id
68 }
69
70 {
71 video = await server.videos.quickUpload({
72 name: 'password protected video',
73 privacy: VideoPrivacy.PASSWORD_PROTECTED,
74 videoPasswords: [ 'password1', 'password2' ]
75 })
76 }
77 path = '/api/v1/videos/'
78 })
79
80 async function checkVideoPasswordOptions (options: {
81 server: PeerTubeServer
82 token: string
83 videoPasswords: string[]
84 expectedStatus: HttpStatusCode
85 mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live'
86 }) {
87 const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options
88 const attaches = {
89 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
90 }
91 const baseCorrectParams = {
92 name: 'my super name',
93 category: 5,
94 licence: 1,
95 language: 'pt',
96 nsfw: false,
97 commentsEnabled: true,
98 downloadEnabled: true,
99 waitTranscoding: true,
100 description: 'my super description',
101 support: 'my super support text',
102 tags: [ 'tag1', 'tag2' ],
103 privacy: VideoPrivacy.PASSWORD_PROTECTED,
104 channelId,
105 originallyPublishedAt: new Date().toISOString()
106 }
107 if (mode === 'uploadLegacy') {
108 const fields = { ...baseCorrectParams, videoPasswords }
109 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy')
110 }
111
112 if (mode === 'uploadResumable') {
113 const fields = { ...baseCorrectParams, videoPasswords }
114 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable')
115 }
116
117 if (mode === 'import') {
118 const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords }
119 return server.imports.importVideo({ attributes, expectedStatus })
120 }
121
122 if (mode === 'updateVideo') {
123 const attributes = { ...baseCorrectParams, videoPasswords }
124 return server.videos.update({ token, expectedStatus, id: video.id, attributes })
125 }
126
127 if (mode === 'updatePasswords') {
128 return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords })
129 }
130
131 if (mode === 'live') {
132 const fields = { ...baseCorrectParams, videoPasswords }
133
134 return server.live.create({ fields, expectedStatus })
135 }
136 }
137
138 function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
139
140 it('Should fail with a password protected privacy without providing a password', async function () {
141 await checkVideoPasswordOptions({
142 server,
143 token: server.accessToken,
144 videoPasswords: undefined,
145 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
146 mode
147 })
148 })
149
150 it('Should fail with a password protected privacy and an empty password list', async function () {
151 const videoPasswords = []
152
153 await checkVideoPasswordOptions({
154 server,
155 token: server.accessToken,
156 videoPasswords,
157 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
158 mode
159 })
160 })
161
162 it('Should fail with a password protected privacy and a too short password', async function () {
163 const videoPasswords = [ 'p' ]
164
165 await checkVideoPasswordOptions({
166 server,
167 token: server.accessToken,
168 videoPasswords,
169 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
170 mode
171 })
172 })
173
174 it('Should fail with a password protected privacy and a too long password', async function () {
175 const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ]
176
177 await checkVideoPasswordOptions({
178 server,
179 token: server.accessToken,
180 videoPasswords,
181 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
182 mode
183 })
184 })
185
186 it('Should fail with a password protected privacy and an empty password', async function () {
187 const videoPasswords = [ '' ]
188
189 await checkVideoPasswordOptions({
190 server,
191 token: server.accessToken,
192 videoPasswords,
193 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
194 mode
195 })
196 })
197
198 it('Should fail with a password protected privacy and duplicated passwords', async function () {
199 const videoPasswords = [ 'password', 'password' ]
200
201 await checkVideoPasswordOptions({
202 server,
203 token: server.accessToken,
204 videoPasswords,
205 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
206 mode
207 })
208 })
209
210 if (mode === 'updatePasswords') {
211 it('Should fail for an unauthenticated user', async function () {
212 const videoPasswords = [ 'password' ]
213 await checkVideoPasswordOptions({
214 server,
215 token: null,
216 videoPasswords,
217 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
218 mode
219 })
220 })
221
222 it('Should fail for an unauthorized user', async function () {
223 const videoPasswords = [ 'password' ]
224 await checkVideoPasswordOptions({
225 server,
226 token: userAccessToken,
227 videoPasswords,
228 expectedStatus: HttpStatusCode.FORBIDDEN_403,
229 mode
230 })
231 })
232 }
233
234 it('Should succeed with a password protected privacy and correct passwords', async function () {
235 const videoPasswords = [ 'password1', 'password2' ]
236 const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo'
237 ? HttpStatusCode.NO_CONTENT_204
238 : HttpStatusCode.OK_200
239
240 await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode })
241 })
242 }
243
244 describe('When adding or updating a video', function () {
245 describe('Resumable upload', function () {
246 validateVideoPasswordList('uploadResumable')
247 })
248
249 describe('Legacy upload', function () {
250 validateVideoPasswordList('uploadLegacy')
251 })
252
253 describe('When importing a video', function () {
254 validateVideoPasswordList('import')
255 })
256
257 describe('When updating a video', function () {
258 validateVideoPasswordList('updateVideo')
259 })
260
261 describe('When updating the password list of a video', function () {
262 validateVideoPasswordList('updatePasswords')
263 })
264
265 describe('When creating a live', function () {
266 validateVideoPasswordList('live')
267 })
268 })
269
270 async function checkVideoAccessOptions (options: {
271 server: PeerTubeServer
272 token?: string
273 videoPassword?: string
274 expectedStatus: HttpStatusCode
275 mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token'
276 }) {
277 const { server, token = null, videoPassword, expectedStatus, mode } = options
278
279 if (mode === 'get') {
280 return server.videos.get({ id: video.id, expectedStatus })
281 }
282
283 if (mode === 'getWithToken') {
284 return server.videos.getWithToken({
285 id: video.id,
286 token,
287 expectedStatus
288 })
289 }
290
291 if (mode === 'getWithPassword') {
292 return server.videos.getWithPassword({
293 id: video.id,
294 token,
295 expectedStatus,
296 password: videoPassword
297 })
298 }
299
300 if (mode === 'rate') {
301 return server.videos.rate({
302 id: video.id,
303 token,
304 expectedStatus,
305 rating: 'like',
306 videoPassword
307 })
308 }
309
310 if (mode === 'createThread') {
311 const fields = { text: 'super comment' }
312 const headers = videoPassword !== undefined && videoPassword !== null
313 ? { 'x-peertube-video-password': videoPassword }
314 : undefined
315 const body = await makePostBodyRequest({
316 url: server.url,
317 path: path + video.uuid + '/comment-threads',
318 token,
319 fields,
320 headers,
321 expectedStatus
322 })
323 return JSON.parse(body.text)
324 }
325
326 if (mode === 'replyThread') {
327 const fields = { text: 'super reply' }
328 const headers = videoPassword !== undefined && videoPassword !== null
329 ? { 'x-peertube-video-password': videoPassword }
330 : undefined
331 return makePostBodyRequest({
332 url: server.url,
333 path: path + video.uuid + '/comments/' + commentId,
334 token,
335 fields,
336 headers,
337 expectedStatus
338 })
339 }
340 if (mode === 'listThreads') {
341 return server.comments.listThreads({
342 videoId: video.id,
343 token,
344 expectedStatus,
345 videoPassword
346 })
347 }
348
349 if (mode === 'listCaptions') {
350 return server.captions.list({
351 videoId: video.id,
352 token,
353 expectedStatus,
354 videoPassword
355 })
356 }
357
358 if (mode === 'token') {
359 return server.videoToken.create({
360 videoId: video.id,
361 token,
362 expectedStatus,
363 videoPassword
364 })
365 }
366 }
367
368 function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') {
369 const serverCode = mode === 'providePassword'
370 ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD
371 : ServerErrorCode.INCORRECT_VIDEO_PASSWORD
372
373 const message = mode === 'providePassword'
374 ? 'Please provide a password to access this password protected video'
375 : 'Incorrect video password. Access to the video is denied.'
376
377 if (!error.code) {
378 error = JSON.parse(error.text)
379 }
380
381 expect(error.code).to.equal(serverCode)
382 expect(error.detail).to.equal(message)
383 expect(error.error).to.equal(message)
384
385 expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
386 }
387
388 function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') {
389 const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
390 let tokens: string[]
391 if (!requiresUserAuth) {
392 it('Should fail without providing a password for an unlogged user', async function () {
393 const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
394 const error = body as unknown as PeerTubeProblemDocument
395
396 checkVideoError(error, 'providePassword')
397 })
398 }
399
400 it('Should fail without providing a password for an unauthorised user', async function () {
401 const tmp = mode === 'get' ? 'getWithToken' : mode
402
403 const body = await checkVideoAccessOptions({
404 server,
405 token: userAccessToken,
406 expectedStatus: HttpStatusCode.FORBIDDEN_403,
407 mode: tmp
408 })
409
410 const error = body as unknown as PeerTubeProblemDocument
411
412 checkVideoError(error, 'providePassword')
413 })
414
415 it('Should fail if a wrong password is entered', async function () {
416 const tmp = mode === 'get' ? 'getWithPassword' : mode
417 tokens = [ userAccessToken, server.accessToken ]
418
419 if (!requiresUserAuth) tokens.push(null)
420
421 for (const token of tokens) {
422 const body = await checkVideoAccessOptions({
423 server,
424 token,
425 videoPassword: 'toto',
426 expectedStatus: HttpStatusCode.FORBIDDEN_403,
427 mode: tmp
428 })
429 const error = body as unknown as PeerTubeProblemDocument
430
431 checkVideoError(error, 'incorrectPassword')
432 }
433 })
434
435 it('Should fail if an empty password is entered', async function () {
436 const tmp = mode === 'get' ? 'getWithPassword' : mode
437
438 for (const token of tokens) {
439 const body = await checkVideoAccessOptions({
440 server,
441 token,
442 videoPassword: '',
443 expectedStatus: HttpStatusCode.FORBIDDEN_403,
444 mode: tmp
445 })
446 const error = body as unknown as PeerTubeProblemDocument
447
448 checkVideoError(error, 'incorrectPassword')
449 }
450 })
451
452 it('Should fail if an inccorect password containing the correct password is entered', async function () {
453 const tmp = mode === 'get' ? 'getWithPassword' : mode
454
455 for (const token of tokens) {
456 const body = await checkVideoAccessOptions({
457 server,
458 token,
459 videoPassword: 'password11',
460 expectedStatus: HttpStatusCode.FORBIDDEN_403,
461 mode: tmp
462 })
463 const error = body as unknown as PeerTubeProblemDocument
464
465 checkVideoError(error, 'incorrectPassword')
466 }
467 })
468
469 it('Should succeed without providing a password for an authorised user', async function () {
470 const tmp = mode === 'get' ? 'getWithToken' : mode
471 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
472
473 const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp })
474
475 if (mode === 'createThread') commentId = body.comment.id
476 })
477
478 it('Should succeed using correct passwords', async function () {
479 const tmp = mode === 'get' ? 'getWithPassword' : mode
480 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
481
482 for (const token of tokens) {
483 await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp })
484 await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp })
485 }
486 })
487 }
488
489 describe('When accessing password protected video', function () {
490
491 describe('For getting a password protected video', function () {
492 validateVideoAccess('get')
493 })
494
495 describe('For rating a video', function () {
496 validateVideoAccess('rate')
497 })
498
499 describe('For creating a thread', function () {
500 validateVideoAccess('createThread')
501 })
502
503 describe('For replying to a thread', function () {
504 validateVideoAccess('replyThread')
505 })
506
507 describe('For listing threads', function () {
508 validateVideoAccess('listThreads')
509 })
510
511 describe('For getting captions', function () {
512 validateVideoAccess('listCaptions')
513 })
514
515 describe('For creating video file token', function () {
516 validateVideoAccess('token')
517 })
518 })
519
520 describe('When listing passwords', function () {
521 it('Should fail with a bad start pagination', async function () {
522 await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
523 })
524
525 it('Should fail with a bad count pagination', async function () {
526 await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
527 })
528
529 it('Should fail with an incorrect sort', async function () {
530 await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
531 })
532
533 it('Should fail for unauthenticated user', async function () {
534 await server.videoPasswords.list({
535 token: null,
536 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
537 videoId: video.id
538 })
539 })
540
541 it('Should fail for unauthorized user', async function () {
542 await server.videoPasswords.list({
543 token: userAccessToken,
544 expectedStatus: HttpStatusCode.FORBIDDEN_403,
545 videoId: video.id
546 })
547 })
548
549 it('Should succeed with the correct parameters', async function () {
550 await server.videoPasswords.list({
551 token: server.accessToken,
552 expectedStatus: HttpStatusCode.OK_200,
553 videoId: video.id
554 })
555 })
556 })
557
558 describe('When deleting a password', async function () {
559 const passwords = (await server.videoPasswords.list({ videoId: video.id })).data
560
561 it('Should fail with wrong password id', async function () {
562 await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
563 })
564
565 it('Should fail for unauthenticated user', async function () {
566 await server.videoPasswords.remove({
567 id: passwords[0].id,
568 token: null,
569 videoId: video.id,
570 expectedStatus: HttpStatusCode.FORBIDDEN_403
571 })
572 })
573
574 it('Should fail for unauthorized user', async function () {
575 await server.videoPasswords.remove({
576 id: passwords[0].id,
577 token: userAccessToken,
578 videoId: video.id,
579 expectedStatus: HttpStatusCode.BAD_REQUEST_400
580 })
581 })
582
583 it('Should fail for non password protected video', async function () {
584 publicVideo = await server.videos.quickUpload({ name: 'public video' })
585 await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
586 })
587
588 it('Should fail for password not linked to correct video', async function () {
589 const video2 = await server.videos.quickUpload({
590 name: 'password protected video',
591 privacy: VideoPrivacy.PASSWORD_PROTECTED,
592 videoPasswords: [ 'password1', 'password2' ]
593 })
594 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
595 })
596
597 it('Should succeed with correct parameter', async function () {
598 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
599 })
600
601 it('Should fail for last password of a video', async function () {
602 await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
603 })
604 })
605
606 after(async function () {
607 await cleanupTests([ server ])
608 })
609})
diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts
index 7acb9d580..7cb3e84a2 100644
--- a/server/tests/api/check-params/video-token.ts
+++ b/server/tests/api/check-params/video-token.ts
@@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
5 5
6describe('Test video tokens', function () { 6describe('Test video tokens', function () {
7 let server: PeerTubeServer 7 let server: PeerTubeServer
8 let videoId: string 8 let privateVideoId: string
9 let passwordProtectedVideoId: string
9 let userToken: string 10 let userToken: string
10 11
12 const videoPassword = 'password'
13
11 // --------------------------------------------------------------- 14 // ---------------------------------------------------------------
12 15
13 before(async function () { 16 before(async function () {
@@ -15,27 +18,50 @@ describe('Test video tokens', function () {
15 18
16 server = await createSingleServer(1) 19 server = await createSingleServer(1)
17 await setAccessTokensToServers([ server ]) 20 await setAccessTokensToServers([ server ])
18 21 {
19 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 22 const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
20 videoId = uuid 23 privateVideoId = uuid
21 24 }
25 {
26 const { uuid } = await server.videos.quickUpload({
27 name: 'password protected video',
28 privacy: VideoPrivacy.PASSWORD_PROTECTED,
29 videoPasswords: [ videoPassword ]
30 })
31 passwordProtectedVideoId = uuid
32 }
22 userToken = await server.users.generateUserAndToken('user1') 33 userToken = await server.users.generateUserAndToken('user1')
23 }) 34 })
24 35
25 it('Should not generate tokens for unauthenticated user', async function () { 36 it('Should not generate tokens on private video for unauthenticated user', async function () {
26 await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 37 await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
27 }) 38 })
28 39
29 it('Should not generate tokens of unknown video', async function () { 40 it('Should not generate tokens of unknown video', async function () {
30 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 41 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
31 }) 42 })
32 43
44 it('Should not generate tokens with incorrect password', async function () {
45 await server.videoToken.create({
46 videoId: passwordProtectedVideoId,
47 token: null,
48 expectedStatus: HttpStatusCode.FORBIDDEN_403,
49 videoPassword: 'incorrectPassword'
50 })
51 })
52
33 it('Should not generate tokens of a non owned video', async function () { 53 it('Should not generate tokens of a non owned video', async function () {
34 await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 54 await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
35 }) 55 })
36 56
37 it('Should generate token', async function () { 57 it('Should generate token', async function () {
38 await server.videoToken.create({ videoId }) 58 await server.videoToken.create({ videoId: privateVideoId })
59 })
60
61 it('Should generate token on password protected video', async function () {
62 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null })
63 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken })
64 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword })
39 }) 65 })
40 66
41 after(async function () { 67 after(async function () {
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index af9d681b2..2a7c3381d 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -107,8 +107,13 @@ describe('Object storage for video static file privacy', function () {
107 describe('VOD', function () { 107 describe('VOD', function () {
108 let privateVideoUUID: string 108 let privateVideoUUID: string
109 let publicVideoUUID: string 109 let publicVideoUUID: string
110 let passwordProtectedVideoUUID: string
110 let userPrivateVideoUUID: string 111 let userPrivateVideoUUID: string
111 112
113 const correctPassword = 'my super password'
114 const correctPasswordHeader = { 'x-peertube-video-password': correctPassword }
115 const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' }
116
112 // --------------------------------------------------------------------------- 117 // ---------------------------------------------------------------------------
113 118
114 async function getSampleFileUrls (videoId: string) { 119 async function getSampleFileUrls (videoId: string) {
@@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
140 await checkPrivateVODFiles(privateVideoUUID) 145 await checkPrivateVODFiles(privateVideoUUID)
141 }) 146 })
142 147
148 it('Should upload a password protected video and have appropriate object storage ACL', async function () {
149 this.timeout(120000)
150
151 {
152 const { uuid } = await server.videos.quickUpload({
153 name: 'video',
154 privacy: VideoPrivacy.PASSWORD_PROTECTED,
155 videoPasswords: [ correctPassword ]
156 })
157 passwordProtectedVideoUUID = uuid
158 }
159 await waitJobs([ server ])
160
161 await checkPrivateVODFiles(passwordProtectedVideoUUID)
162 })
163
143 it('Should upload a public video and have appropriate object storage ACL', async function () { 164 it('Should upload a public video and have appropriate object storage ACL', async function () {
144 this.timeout(120000) 165 this.timeout(120000)
145 166
@@ -163,6 +184,42 @@ describe('Object storage for video static file privacy', function () {
163 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 184 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
164 }) 185 })
165 186
187 it('Should not get files without appropriate password or appropriate OAuth token', async function () {
188 this.timeout(60000)
189
190 const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
191
192 await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
193 await makeRawRequest({
194 url: webTorrentFile,
195 token: null,
196 headers: incorrectPasswordHeader,
197 expectedStatus: HttpStatusCode.FORBIDDEN_403
198 })
199 await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
200 await makeRawRequest({
201 url: webTorrentFile,
202 token: null,
203 headers: correctPasswordHeader,
204 expectedStatus: HttpStatusCode.OK_200
205 })
206
207 await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
208 await makeRawRequest({
209 url: hlsFile,
210 token: null,
211 headers: incorrectPasswordHeader,
212 expectedStatus: HttpStatusCode.FORBIDDEN_403
213 })
214 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
215 await makeRawRequest({
216 url: hlsFile,
217 token: null,
218 headers: correctPasswordHeader,
219 expectedStatus: HttpStatusCode.OK_200
220 })
221 })
222
166 it('Should not get HLS file of another video', async function () { 223 it('Should not get HLS file of another video', async function () {
167 this.timeout(60000) 224 this.timeout(60000)
168 225
@@ -176,7 +233,7 @@ describe('Object storage for video static file privacy', function () {
176 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 233 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
177 }) 234 })
178 235
179 it('Should correctly check OAuth or video file token', async function () { 236 it('Should correctly check OAuth, video file token of private video', async function () {
180 this.timeout(60000) 237 this.timeout(60000)
181 238
182 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) 239 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
@@ -191,6 +248,35 @@ describe('Object storage for video static file privacy', function () {
191 248
192 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 249 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
193 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) 250 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
251
252 }
253 })
254
255 it('Should correctly check OAuth, video file token or video password of password protected video', async function () {
256 this.timeout(60000)
257
258 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
259 const goodVideoFileToken = await server.videoToken.getVideoFileToken({
260 videoId: passwordProtectedVideoUUID,
261 videoPassword: correctPassword
262 })
263
264 const { webTorrentFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
265
266 for (const url of [ hlsFile, webTorrentFile ]) {
267 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
268 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
269 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
270
271 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
272 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
273
274 await makeRawRequest({
275 url,
276 headers: incorrectPasswordHeader,
277 expectedStatus: HttpStatusCode.FORBIDDEN_403
278 })
279 await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 })
194 } 280 }
195 }) 281 })
196 282
@@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () {
232 let permanentLiveId: string 318 let permanentLiveId: string
233 let permanentLive: LiveVideo 319 let permanentLive: LiveVideo
234 320
321 let passwordProtectedLiveId: string
322 let passwordProtectedLive: LiveVideo
323
324 const correctPassword = 'my super password'
325
235 let unrelatedFileToken: string 326 let unrelatedFileToken: string
236 327
237 // --------------------------------------------------------------------------- 328 // ---------------------------------------------------------------------------
238 329
239 async function checkLiveFiles (live: LiveVideo, liveId: string) { 330 async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) {
240 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 331 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
241 await server.live.waitUntilPublished({ videoId: liveId }) 332 await server.live.waitUntilPublished({ videoId: liveId })
242 333
243 const video = await server.videos.getWithToken({ id: liveId }) 334 const video = videoPassword
244 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 335 ? await server.videos.getWithPassword({ id: liveId, password: videoPassword })
336 : await server.videos.getWithToken({ id: liveId })
337
338 const fileToken = videoPassword
339 ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword })
340 : await server.videoToken.getVideoFileToken({ videoId: video.uuid })
245 341
246 const hls = video.streamingPlaylists[0] 342 const hls = video.streamingPlaylists[0]
247 343
@@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () {
253 349
254 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 350 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
255 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) 351 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
256 352 if (videoPassword) {
353 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
354 }
257 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 355 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
258 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 356 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
259 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 357 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
358 if (videoPassword) {
359 await makeRawRequest({
360 url,
361 headers: { 'x-peertube-video-password': 'incorrectPassword' },
362 expectedStatus: HttpStatusCode.FORBIDDEN_403
363 })
364 }
260 } 365 }
261 366
262 await stopFfmpeg(ffmpegCommand) 367 await stopFfmpeg(ffmpegCommand)
@@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
326 permanentLiveId = video.uuid 431 permanentLiveId = video.uuid
327 permanentLive = live 432 permanentLive = live
328 } 433 }
434
435 {
436 const { video, live } = await server.live.quickCreate({
437 saveReplay: false,
438 permanentLive: false,
439 privacy: VideoPrivacy.PASSWORD_PROTECTED,
440 videoPasswords: [ correctPassword ]
441 })
442 passwordProtectedLiveId = video.uuid
443 passwordProtectedLive = live
444 }
329 }) 445 })
330 446
331 it('Should create a private normal live and have a private static path', async function () { 447 it('Should create a private normal live and have a private static path', async function () {
@@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () {
340 await checkLiveFiles(permanentLive, permanentLiveId) 456 await checkLiveFiles(permanentLive, permanentLiveId)
341 }) 457 })
342 458
459 it('Should create a password protected live and have a private static path', async function () {
460 this.timeout(240000)
461
462 await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword)
463 })
464
343 it('Should reinject video file token in permanent live', async function () { 465 it('Should reinject video file token in permanent live', async function () {
344 this.timeout(240000) 466 this.timeout(240000)
345 467
diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts
new file mode 100644
index 000000000..e01a93a4d
--- /dev/null
+++ b/server/tests/api/videos/video-passwords.ts
@@ -0,0 +1,97 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import {
5 cleanupTests,
6 createSingleServer,
7 VideoPasswordsCommand,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 setDefaultAccountAvatar,
11 setDefaultChannelAvatar
12} from '@shared/server-commands'
13import { VideoPrivacy } from '@shared/models'
14
15describe('Test video passwords', function () {
16 let server: PeerTubeServer
17 let videoUUID: string
18
19 let userAccessTokenServer1: string
20
21 let videoPasswords: string[] = []
22 let command: VideoPasswordsCommand
23
24 before(async function () {
25 this.timeout(30000)
26
27 server = await createSingleServer(1)
28
29 await setAccessTokensToServers([ server ])
30
31 for (let i = 0; i < 10; i++) {
32 videoPasswords.push(`password ${i + 1}`)
33 }
34 const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } })
35 videoUUID = uuid
36
37 await setDefaultChannelAvatar(server)
38 await setDefaultAccountAvatar(server)
39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
43
44 command = server.videoPasswords
45 })
46
47 it('Should list video passwords', async function () {
48 const body = await command.list({ videoId: videoUUID })
49
50 expect(body.total).to.equal(10)
51 expect(body.data).to.be.an('array')
52 expect(body.data).to.have.lengthOf(10)
53 })
54
55 it('Should filter passwords on this video', async function () {
56 const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' })
57
58 expect(body.total).to.equal(10)
59 expect(body.data).to.be.an('array')
60 expect(body.data).to.have.lengthOf(2)
61 expect(body.data[0].password).to.equal('password 4')
62 expect(body.data[1].password).to.equal('password 5')
63 })
64
65 it('Should update password for this video', async function () {
66 videoPasswords = [ 'my super new password 1', 'my super new password 2' ]
67
68 await command.updateAll({ videoId: videoUUID, passwords: videoPasswords })
69 const body = await command.list({ videoId: videoUUID })
70 expect(body.total).to.equal(2)
71 expect(body.data).to.be.an('array')
72 expect(body.data).to.have.lengthOf(2)
73 expect(body.data[0].password).to.equal('my super new password 2')
74 expect(body.data[1].password).to.equal('my super new password 1')
75 })
76
77 it('Should delete one password', async function () {
78 {
79 const body = await command.list({ videoId: videoUUID })
80 expect(body.total).to.equal(2)
81 expect(body.data).to.be.an('array')
82 expect(body.data).to.have.lengthOf(2)
83 await command.remove({ id: body.data[0].id, videoId: videoUUID })
84 }
85 {
86 const body = await command.list({ videoId: videoUUID })
87
88 expect(body.total).to.equal(1)
89 expect(body.data).to.be.an('array')
90 expect(body.data).to.have.lengthOf(1)
91 }
92 })
93
94 after(async function () {
95 await cleanupTests([ server ])
96 })
97})
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index d9c5bdf16..9277b49f4 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -474,7 +474,7 @@ describe('Test video playlists', function () {
474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) 474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
475 }) 475 })
476 476
477 it('Should get unlisted plyaylist using uuid or shortUUID', async function () { 477 it('Should get unlisted playlist using uuid or shortUUID', async function () {
478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) 478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) 479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
480 }) 480 })
@@ -686,7 +686,7 @@ describe('Test video playlists', function () {
686 await waitJobs(servers) 686 await waitJobs(servers)
687 }) 687 })
688 688
689 it('Should update the element type if the video is private', async function () { 689 it('Should update the element type if the video is private/password protected', async function () {
690 this.timeout(20000) 690 this.timeout(20000)
691 691
692 const name = 'video 89' 692 const name = 'video 89'
@@ -703,6 +703,19 @@ describe('Test video playlists', function () {
703 } 703 }
704 704
705 { 705 {
706 await servers[0].videos.update({
707 id: video1,
708 attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
709 })
710 await waitJobs(servers)
711
712 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
713 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
714 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
715 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
716 }
717
718 {
706 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) 719 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
707 await waitJobs(servers) 720 await waitJobs(servers)
708 721
diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts
index 542848533..ec4c697db 100644
--- a/server/tests/api/videos/video-static-file-privacy.ts
+++ b/server/tests/api/videos/video-static-file-privacy.ts
@@ -90,7 +90,7 @@ describe('Test video static file privacy', function () {
90 } 90 }
91 } 91 }
92 92
93 it('Should upload a private/internal video and have a private static path', async function () { 93 it('Should upload a private/internal/password protected video and have a private static path', async function () {
94 this.timeout(120000) 94 this.timeout(120000)
95 95
96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { 96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
@@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
99 99
100 await checkPrivateFiles(uuid) 100 await checkPrivateFiles(uuid)
101 } 101 }
102
103 const { uuid } = await server.videos.quickUpload({
104 name: 'video',
105 privacy: VideoPrivacy.PASSWORD_PROTECTED,
106 videoPasswords: [ 'my super password' ]
107 })
108 await waitJobs([ server ])
109
110 await checkPrivateFiles(uuid)
102 }) 111 })
103 112
104 it('Should upload a public video and update it as private/internal to have a private static path', async function () { 113 it('Should upload a public video and update it as private/internal to have a private static path', async function () {
@@ -185,8 +194,9 @@ describe('Test video static file privacy', function () {
185 expectedStatus: HttpStatusCode 194 expectedStatus: HttpStatusCode
186 token: string 195 token: string
187 videoFileToken: string 196 videoFileToken: string
197 videoPassword?: string
188 }) { 198 }) {
189 const { id, expectedStatus, token, videoFileToken } = options 199 const { id, expectedStatus, token, videoFileToken, videoPassword } = options
190 200
191 const video = await server.videos.getWithToken({ id }) 201 const video = await server.videos.getWithToken({ id })
192 202
@@ -196,6 +206,12 @@ describe('Test video static file privacy', function () {
196 206
197 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) 207 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
198 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) 208 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
209
210 if (videoPassword) {
211 const headers = { 'x-peertube-video-password': videoPassword }
212 await makeRawRequest({ url: file.fileUrl, headers, expectedStatus })
213 await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus })
214 }
199 } 215 }
200 216
201 const hls = video.streamingPlaylists[0] 217 const hls = video.streamingPlaylists[0]
@@ -204,6 +220,12 @@ describe('Test video static file privacy', function () {
204 220
205 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) 221 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
206 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) 222 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
223
224 if (videoPassword) {
225 const headers = { 'x-peertube-video-password': videoPassword }
226 await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus })
227 await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus })
228 }
207 } 229 }
208 230
209 before(async function () { 231 before(async function () {
@@ -216,13 +238,53 @@ describe('Test video static file privacy', function () {
216 it('Should not be able to access a private video files without OAuth token and file token', async function () { 238 it('Should not be able to access a private video files without OAuth token and file token', async function () {
217 this.timeout(120000) 239 this.timeout(120000)
218 240
219 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) 241 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
220 await waitJobs([ server ]) 242 await waitJobs([ server ])
221 243
222 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) 244 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null })
223 }) 245 })
224 246
225 it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { 247 it('Should not be able to access password protected video files without OAuth token, file token and password', async function () {
248 this.timeout(120000)
249 const videoPassword = 'my super password'
250
251 const { uuid } = await server.videos.quickUpload({
252 name: 'password protected video',
253 privacy: VideoPrivacy.PASSWORD_PROTECTED,
254 videoPasswords: [ videoPassword ]
255 })
256 await waitJobs([ server ])
257
258 await checkVideoFiles({
259 id: uuid,
260 expectedStatus: HttpStatusCode.FORBIDDEN_403,
261 token: null,
262 videoFileToken: null,
263 videoPassword: null
264 })
265 })
266
267 it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () {
268 this.timeout(120000)
269 const videoPassword = 'my super password'
270
271 const { uuid } = await server.videos.quickUpload({
272 name: 'password protected video',
273 privacy: VideoPrivacy.PASSWORD_PROTECTED,
274 videoPasswords: [ videoPassword ]
275 })
276 await waitJobs([ server ])
277
278 await checkVideoFiles({
279 id: uuid,
280 expectedStatus: HttpStatusCode.FORBIDDEN_403,
281 token: userToken,
282 videoFileToken: unrelatedFileToken,
283 videoPassword: 'incorrectPassword'
284 })
285 })
286
287 it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () {
226 this.timeout(120000) 288 this.timeout(120000)
227 289
228 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 290 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -247,6 +309,23 @@ describe('Test video static file privacy', function () {
247 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) 309 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
248 }) 310 })
249 311
312 it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () {
313 this.timeout(120000)
314 const videoPassword = 'my super password'
315
316 const { uuid } = await server.videos.quickUpload({
317 name: 'video',
318 privacy: VideoPrivacy.PASSWORD_PROTECTED,
319 videoPasswords: [ videoPassword ]
320 })
321
322 const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword })
323
324 await waitJobs([ server ])
325
326 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword })
327 })
328
250 it('Should reinject video file token', async function () { 329 it('Should reinject video file token', async function () {
251 this.timeout(120000) 330 this.timeout(120000)
252 331
@@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
294 let permanentLiveId: string 373 let permanentLiveId: string
295 let permanentLive: LiveVideo 374 let permanentLive: LiveVideo
296 375
376 let passwordProtectedLiveId: string
377 let passwordProtectedLive: LiveVideo
378
379 const correctPassword = 'my super password'
380
297 let unrelatedFileToken: string 381 let unrelatedFileToken: string
298 382
299 async function checkLiveFiles (live: LiveVideo, liveId: string) { 383 async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) {
384 const { live, liveId, videoPassword } = options
300 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 385 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
301 await server.live.waitUntilPublished({ videoId: liveId }) 386 await server.live.waitUntilPublished({ videoId: liveId })
302 387
303 const video = await server.videos.getWithToken({ id: liveId }) 388 const video = await server.videos.getWithToken({ id: liveId })
389
304 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 390 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
305 391
306 const hls = video.streamingPlaylists[0] 392 const hls = video.streamingPlaylists[0]
@@ -314,6 +400,16 @@ describe('Test video static file privacy', function () {
314 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 400 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
315 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 401 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
316 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 402 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
403
404 if (videoPassword) {
405 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
406 await makeRawRequest({
407 url,
408 headers: { 'x-peertube-video-password': 'incorrectPassword' },
409 expectedStatus: HttpStatusCode.FORBIDDEN_403
410 })
411 }
412
317 } 413 }
318 414
319 await stopFfmpeg(ffmpegCommand) 415 await stopFfmpeg(ffmpegCommand)
@@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
381 permanentLiveId = video.uuid 477 permanentLiveId = video.uuid
382 permanentLive = live 478 permanentLive = live
383 } 479 }
480
481 {
482 const { video, live } = await server.live.quickCreate({
483 saveReplay: false,
484 permanentLive: false,
485 privacy: VideoPrivacy.PASSWORD_PROTECTED,
486 videoPasswords: [ correctPassword ]
487 })
488 passwordProtectedLiveId = video.uuid
489 passwordProtectedLive = live
490 }
384 }) 491 })
385 492
386 it('Should create a private normal live and have a private static path', async function () { 493 it('Should create a private normal live and have a private static path', async function () {
387 this.timeout(240000) 494 this.timeout(240000)
388 495
389 await checkLiveFiles(normalLive, normalLiveId) 496 await checkLiveFiles({ live: normalLive, liveId: normalLiveId })
390 }) 497 })
391 498
392 it('Should create a private permanent live and have a private static path', async function () { 499 it('Should create a private permanent live and have a private static path', async function () {
393 this.timeout(240000) 500 this.timeout(240000)
394 501
395 await checkLiveFiles(permanentLive, permanentLiveId) 502 await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId })
503 })
504
505 it('Should create a password protected live and have a private static path', async function () {
506 this.timeout(240000)
507
508 await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword })
396 }) 509 })
397 510
398 it('Should reinject video file token on permanent live', async function () { 511 it('Should reinject video file token on permanent live', async function () {
diff --git a/server/tests/client.ts b/server/tests/client.ts
index e84251561..68f3a1d14 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -56,6 +56,7 @@ describe('Test a client controllers', function () {
56 let privateVideoId: string 56 let privateVideoId: string
57 let internalVideoId: string 57 let internalVideoId: string
58 let unlistedVideoId: string 58 let unlistedVideoId: string
59 let passwordProtectedVideoId: string
59 60
60 let playlistIds: (string | number)[] = [] 61 let playlistIds: (string | number)[] = []
61 62
@@ -92,7 +93,12 @@ describe('Test a client controllers', function () {
92 { 93 {
93 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); 94 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
94 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); 95 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
95 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })) 96 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
97 ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
98 name: 'password protected',
99 privacy: VideoPrivacy.PASSWORD_PROTECTED,
100 videoPasswords: [ 'password' ]
101 }))
96 } 102 }
97 103
98 // Playlist 104 // Playlist
@@ -502,9 +508,9 @@ describe('Test a client controllers', function () {
502 } 508 }
503 }) 509 })
504 510
505 it('Should not display internal/private video', async function () { 511 it('Should not display internal/private/password protected video', async function () {
506 for (const basePath of watchVideoBasePaths) { 512 for (const basePath of watchVideoBasePaths) {
507 for (const id of [ privateVideoId, internalVideoId ]) { 513 for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
508 const res = await makeGetRequest({ 514 const res = await makeGetRequest({
509 url: servers[0].url, 515 url: servers[0].url,
510 path: basePath + id, 516 path: basePath + id,
@@ -514,6 +520,7 @@ describe('Test a client controllers', function () {
514 520
515 expect(res.text).to.not.contain('internal') 521 expect(res.text).to.not.contain('internal')
516 expect(res.text).to.not.contain('private') 522 expect(res.text).to.not.contain('private')
523 expect(res.text).to.not.contain('password protected')
517 } 524 }
518 } 525 }
519 }) 526 })
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 8433c873e..83a85be58 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -99,6 +99,13 @@ describe('Test syndication feeds', () => {
99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) 99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
100 } 100 }
101 101
102 {
103 const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
104 const { id } = await servers[0].videos.upload({ attributes })
105
106 await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
107 }
108
102 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) 109 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
103 110
104 await waitJobs([ ...servers, serverHLSOnly ]) 111 await waitJobs([ ...servers, serverHLSOnly ])
@@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
445 452
446 describe('Video comments feed', function () { 453 describe('Video comments feed', function () {
447 454
448 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { 455 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
449 for (const server of servers) { 456 for (const server of servers) {
450 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) 457 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
451 458
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 510b9f94e..9c1be9bd1 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -18,6 +18,7 @@ import {
18 MVideoId, 18 MVideoId,
19 MVideoImmutable, 19 MVideoImmutable,
20 MVideoLiveFormattable, 20 MVideoLiveFormattable,
21 MVideoPassword,
21 MVideoPlaylistFull, 22 MVideoPlaylistFull,
22 MVideoPlaylistFullSummary 23 MVideoPlaylistFullSummary
23} from '@server/types/models' 24} from '@server/types/models'
@@ -165,6 +166,8 @@ declare module 'express' {
165 videoCommentFull?: MCommentOwnerVideoReply 166 videoCommentFull?: MCommentOwnerVideoReply
166 videoCommentThread?: MComment 167 videoCommentThread?: MComment
167 168
169 videoPassword?: MVideoPassword
170
168 follow?: MActorFollowActorsDefault 171 follow?: MActorFollowActorsDefault
169 subscription?: MActorFollowActorsDefaultSubscription 172 subscription?: MActorFollowActorsDefaultSubscription
170 173
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 6e45fcc79..0ac032290 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -16,6 +16,7 @@ export * from './video-import'
16export * from './video-live-replay-setting' 16export * from './video-live-replay-setting'
17export * from './video-live-session' 17export * from './video-live-session'
18export * from './video-live' 18export * from './video-live'
19export * from './video-password'
19export * from './video-playlist' 20export * from './video-playlist'
20export * from './video-playlist-element' 21export * from './video-playlist-element'
21export * from './video-rate' 22export * from './video-rate'
diff --git a/server/types/models/video/video-password.ts b/server/types/models/video/video-password.ts
new file mode 100644
index 000000000..313cc3e0c
--- /dev/null
+++ b/server/types/models/video/video-password.ts
@@ -0,0 +1,3 @@
1import { VideoPasswordModel } from '@server/models/video/video-password'
2
3export type MVideoPassword = Omit<VideoPasswordModel, 'Video'>
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts
index 58ae7baad..8021e56bb 100644
--- a/server/types/models/video/video.ts
+++ b/server/types/models/video/video.ts
@@ -32,7 +32,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
32export type MVideo = 32export type MVideo =
33 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | 33 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
34 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | 34 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
35 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers'> 35 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'>
36 36
37// ############################################################################ 37// ############################################################################
38 38
@@ -46,7 +46,7 @@ export type MVideoFeed = Pick<MVideo, 'name' | 'uuid'>
46 46
47// ############################################################################ 47// ############################################################################
48 48
49// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists 49// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords
50 50
51// "With" to not confuse with the VideoFile model 51// "With" to not confuse with the VideoFile model
52export type MVideoWithFile = 52export type MVideoWithFile =