diff options
author | Wicklow <123956049+wickloww@users.noreply.github.com> | 2023-06-29 07:48:55 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-06-29 09:48:55 +0200 |
commit | 40346ead2b0b7afa475aef057d3673b6c7574b7a (patch) | |
tree | 24ffdc23c3a9d987334842e0d400b5bd44500cf7 /server/middlewares/validators/shared | |
parent | ae22c59f14d0d553f60b281948b6c232c2aca178 (diff) | |
download | PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.gz PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.tar.zst PeerTube-40346ead2b0b7afa475aef057d3673b6c7574b7a.zip |
Feature/password protected videos (#5836)
* Add server endpoints
* Refactoring test suites
* Update server and add openapi documentation
* fix compliation and tests
* upload/import password protected video on client
* add server error code
* Add video password to update resolver
* add custom message when sharing pw protected video
* improve confirm component
* Add new alert in component
* Add ability to watch protected video on client
* Cannot have password protected replay privacy
* Add migration
* Add tests
* update after review
* Update check params tests
* Add live videos test
* Add more filter test
* Update static file privacy test
* Update object storage tests
* Add test on feeds
* Add missing word
* Fix tests
* Fix tests on live videos
* add embed support on password protected videos
* fix style
* Correcting data leaks
* Unable to add password protected privacy on replay
* Updated code based on review comments
* fix validator and command
* Updated code based on review comments
Diffstat (limited to 'server/middlewares/validators/shared')
-rw-r--r-- | server/middlewares/validators/shared/index.ts | 1 | ||||
-rw-r--r-- | server/middlewares/validators/shared/video-passwords.ts | 80 | ||||
-rw-r--r-- | server/middlewares/validators/shared/videos.ts | 85 |
3 files changed, 154 insertions, 12 deletions
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts index de98cd442..e5cff2dda 100644 --- a/server/middlewares/validators/shared/index.ts +++ b/server/middlewares/validators/shared/index.ts | |||
@@ -10,4 +10,5 @@ export * from './video-comments' | |||
10 | export * from './video-imports' | 10 | export * from './video-imports' |
11 | export * from './video-ownerships' | 11 | export * from './video-ownerships' |
12 | export * from './video-playlists' | 12 | export * from './video-playlists' |
13 | export * from './video-passwords' | ||
13 | export * from './videos' | 14 | export * from './videos' |
diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts new file mode 100644 index 000000000..efcc95dc4 --- /dev/null +++ b/server/middlewares/validators/shared/video-passwords.ts | |||
@@ -0,0 +1,80 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
5 | import { header } from 'express-validator' | ||
6 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
7 | |||
8 | function isValidVideoPasswordHeader () { | ||
9 | return header('x-peertube-video-password') | ||
10 | .optional() | ||
11 | .isString() | ||
12 | } | ||
13 | |||
14 | function checkVideoIsPasswordProtected (res: express.Response) { | ||
15 | const video = getVideoWithAttributes(res) | ||
16 | if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) { | ||
17 | res.fail({ | ||
18 | status: HttpStatusCode.BAD_REQUEST_400, | ||
19 | message: 'Video is not password protected' | ||
20 | }) | ||
21 | return false | ||
22 | } | ||
23 | |||
24 | return true | ||
25 | } | ||
26 | |||
27 | async function doesVideoPasswordExist (idArg: number | string, res: express.Response) { | ||
28 | const video = getVideoWithAttributes(res) | ||
29 | const id = forceNumber(idArg) | ||
30 | const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id }) | ||
31 | |||
32 | if (!videoPassword) { | ||
33 | res.fail({ | ||
34 | status: HttpStatusCode.NOT_FOUND_404, | ||
35 | message: 'Video password not found' | ||
36 | }) | ||
37 | return false | ||
38 | } | ||
39 | |||
40 | res.locals.videoPassword = videoPassword | ||
41 | |||
42 | return true | ||
43 | } | ||
44 | |||
45 | async function isVideoPasswordDeletable (res: express.Response) { | ||
46 | const user = res.locals.oauth.token.User | ||
47 | const userAccount = user.Account | ||
48 | const video = res.locals.videoAll | ||
49 | |||
50 | // Check if the user who did the request is able to delete the video passwords | ||
51 | if ( | ||
52 | user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator | ||
53 | video.VideoChannel.accountId !== userAccount.id // Not the video owner | ||
54 | ) { | ||
55 | res.fail({ | ||
56 | status: HttpStatusCode.FORBIDDEN_403, | ||
57 | message: 'Cannot remove passwords of another user\'s video' | ||
58 | }) | ||
59 | return false | ||
60 | } | ||
61 | |||
62 | const passwordCount = await VideoPasswordModel.countByVideoId(video.id) | ||
63 | |||
64 | if (passwordCount <= 1) { | ||
65 | res.fail({ | ||
66 | status: HttpStatusCode.BAD_REQUEST_400, | ||
67 | message: 'Cannot delete the last password of the protected video' | ||
68 | }) | ||
69 | return false | ||
70 | } | ||
71 | |||
72 | return true | ||
73 | } | ||
74 | |||
75 | export { | ||
76 | isValidVideoPasswordHeader, | ||
77 | checkVideoIsPasswordProtected as isVideoPasswordProtected, | ||
78 | doesVideoPasswordExist, | ||
79 | isVideoPasswordDeletable | ||
80 | } | ||
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index 0033a32ff..9a7497007 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts | |||
@@ -20,6 +20,8 @@ import { | |||
20 | MVideoWithRights | 20 | MVideoWithRights |
21 | } from '@server/types/models' | 21 | } from '@server/types/models' |
22 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' | 22 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' |
23 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
24 | import { exists } from '@server/helpers/custom-validators/misc' | ||
23 | 25 | ||
24 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { | 26 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { |
25 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined | 27 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: { | |||
111 | }) { | 113 | }) { |
112 | const { req, res, video, paramId } = options | 114 | const { req, res, video, paramId } = options |
113 | 115 | ||
114 | if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { | 116 | if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) { |
115 | return checkCanSeeAuthVideo(req, res, video) | 117 | return checkCanSeeUserAuthVideo({ req, res, video }) |
118 | } | ||
119 | |||
120 | if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
121 | return checkCanSeePasswordProtectedVideo({ req, res, video }) | ||
116 | } | 122 | } |
117 | 123 | ||
118 | if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { | 124 | if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { |
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: { | |||
122 | throw new Error('Unknown video privacy when checking video right ' + video.url) | 128 | throw new Error('Unknown video privacy when checking video right ' + video.url) |
123 | } | 129 | } |
124 | 130 | ||
125 | async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { | 131 | async function checkCanSeeUserAuthVideo (options: { |
132 | req: Request | ||
133 | res: Response | ||
134 | video: MVideoId | MVideoWithRights | ||
135 | }) { | ||
136 | const { req, res, video } = options | ||
137 | |||
126 | const fail = () => { | 138 | const fail = () => { |
127 | res.fail({ | 139 | res.fail({ |
128 | status: HttpStatusCode.FORBIDDEN_403, | 140 | status: HttpStatusCode.FORBIDDEN_403, |
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
132 | return false | 144 | return false |
133 | } | 145 | } |
134 | 146 | ||
135 | await authenticatePromise(req, res) | 147 | await authenticatePromise({ req, res }) |
136 | 148 | ||
137 | const user = res.locals.oauth?.token.User | 149 | const user = res.locals.oauth?.token.User |
138 | if (!user) return fail() | 150 | if (!user) return fail() |
139 | 151 | ||
140 | const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId | 152 | const videoWithRights = await getVideoWithRights(video as MVideoWithRights) |
141 | ? video as MVideoWithRights | ||
142 | : await VideoModel.loadFull(video.id) | ||
143 | 153 | ||
144 | const privacy = videoWithRights.privacy | 154 | const privacy = videoWithRights.privacy |
145 | 155 | ||
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
148 | return true | 158 | return true |
149 | } | 159 | } |
150 | 160 | ||
151 | const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id | ||
152 | |||
153 | if (videoWithRights.isBlacklisted()) { | 161 | if (videoWithRights.isBlacklisted()) { |
154 | if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true | 162 | if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true |
155 | 163 | ||
156 | return fail() | 164 | return fail() |
157 | } | 165 | } |
158 | 166 | ||
159 | if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { | 167 | if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { |
160 | if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true | 168 | if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true |
161 | 169 | ||
162 | return fail() | 170 | return fail() |
163 | } | 171 | } |
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
166 | return fail() | 174 | return fail() |
167 | } | 175 | } |
168 | 176 | ||
177 | async function checkCanSeePasswordProtectedVideo (options: { | ||
178 | req: Request | ||
179 | res: Response | ||
180 | video: MVideo | ||
181 | }) { | ||
182 | const { req, res, video } = options | ||
183 | |||
184 | const videoWithRights = await getVideoWithRights(video as MVideoWithRights) | ||
185 | |||
186 | const videoPassword = req.header('x-peertube-video-password') | ||
187 | |||
188 | if (!exists(videoPassword)) { | ||
189 | const errorMessage = 'Please provide a password to access this password protected video' | ||
190 | const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD | ||
191 | |||
192 | if (req.header('authorization')) { | ||
193 | await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType }) | ||
194 | const user = res.locals.oauth?.token.User | ||
195 | |||
196 | if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true | ||
197 | } | ||
198 | |||
199 | res.fail({ | ||
200 | status: HttpStatusCode.FORBIDDEN_403, | ||
201 | type: errorType, | ||
202 | message: errorMessage | ||
203 | }) | ||
204 | return false | ||
205 | } | ||
206 | |||
207 | if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true | ||
208 | |||
209 | res.fail({ | ||
210 | status: HttpStatusCode.FORBIDDEN_403, | ||
211 | type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD, | ||
212 | message: 'Incorrect video password. Access to the video is denied.' | ||
213 | }) | ||
214 | |||
215 | return false | ||
216 | } | ||
217 | |||
218 | function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) { | ||
219 | const isOwnedByUser = video.VideoChannel.Account.userId === user.id | ||
220 | |||
221 | return isOwnedByUser || user.hasRight(right) | ||
222 | } | ||
223 | |||
224 | async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> { | ||
225 | return video.VideoChannel?.Account?.userId | ||
226 | ? video | ||
227 | : VideoModel.loadFull(video.id) | ||
228 | } | ||
229 | |||
169 | // --------------------------------------------------------------------------- | 230 | // --------------------------------------------------------------------------- |
170 | 231 | ||
171 | async function checkCanAccessVideoStaticFiles (options: { | 232 | async function checkCanAccessVideoStaticFiles (options: { |
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: { | |||
176 | }) { | 237 | }) { |
177 | const { video, req, res } = options | 238 | const { video, req, res } = options |
178 | 239 | ||
179 | if (res.locals.oauth?.token.User) { | 240 | if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) { |
180 | return checkCanSeeVideo(options) | 241 | return checkCanSeeVideo(options) |
181 | } | 242 | } |
182 | 243 | ||