diff options
author | Chocobozzz <me@florianbigard.com> | 2022-06-22 09:44:08 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-06-22 10:25:31 +0200 |
commit | ff9d43f62a4f4737c5bfe955883b48c5440f323a (patch) | |
tree | 60593f4b57ec5cd712986a3db370f39b0b7a4cef /server/middlewares/validators | |
parent | 2e401e8575decb1d491d0db48ca1ab1eba5b2a66 (diff) | |
download | PeerTube-ff9d43f62a4f4737c5bfe955883b48c5440f323a.tar.gz PeerTube-ff9d43f62a4f4737c5bfe955883b48c5440f323a.tar.zst PeerTube-ff9d43f62a4f4737c5bfe955883b48c5440f323a.zip |
Refactor video rights checker
Diffstat (limited to 'server/middlewares/validators')
-rw-r--r-- | server/middlewares/validators/feeds.ts | 6 | ||||
-rw-r--r-- | server/middlewares/validators/shared/videos.ts | 84 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-captions.ts | 4 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-comments.ts | 10 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-rates.ts | 4 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 28 |
6 files changed, 86 insertions, 50 deletions
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts index f8ebaf6ed..04b4e00c9 100644 --- a/server/middlewares/validators/feeds.ts +++ b/server/middlewares/validators/feeds.ts | |||
@@ -6,6 +6,7 @@ import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helper | |||
6 | import { logger } from '../../helpers/logger' | 6 | import { logger } from '../../helpers/logger' |
7 | import { | 7 | import { |
8 | areValidationErrors, | 8 | areValidationErrors, |
9 | checkCanSeeVideo, | ||
9 | doesAccountIdExist, | 10 | doesAccountIdExist, |
10 | doesAccountNameWithHostExist, | 11 | doesAccountNameWithHostExist, |
11 | doesUserFeedTokenCorrespond, | 12 | doesUserFeedTokenCorrespond, |
@@ -112,7 +113,10 @@ const videoCommentsFeedsValidator = [ | |||
112 | return res.fail({ message: 'videoId cannot be mixed with a channel filter' }) | 113 | return res.fail({ message: 'videoId cannot be mixed with a channel filter' }) |
113 | } | 114 | } |
114 | 115 | ||
115 | if (req.query.videoId && !await doesVideoExist(req.query.videoId, res)) return | 116 | if (req.query.videoId) { |
117 | if (!await doesVideoExist(req.query.videoId, res)) return | ||
118 | if (!await checkCanSeeVideo({ req, res, paramId: req.query.videoId, video: res.locals.videoAll })) return | ||
119 | } | ||
116 | 120 | ||
117 | return next() | 121 | return next() |
118 | } | 122 | } |
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index 8807435f6..39aab6df7 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { Request, Response } from 'express' | 1 | import { Request, Response } from 'express' |
2 | import { isUUIDValid } from '@server/helpers/custom-validators/misc' | ||
2 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' | 3 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' |
3 | import { isAbleToUploadVideo } from '@server/lib/user' | 4 | import { isAbleToUploadVideo } from '@server/lib/user' |
4 | import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' | 5 | import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' |
@@ -18,18 +19,19 @@ import { | |||
18 | MVideoThumbnail, | 19 | MVideoThumbnail, |
19 | MVideoWithRights | 20 | MVideoWithRights |
20 | } from '@server/types/models' | 21 | } from '@server/types/models' |
21 | import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models' | 22 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' |
22 | 23 | ||
23 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { | 24 | async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { |
24 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined | 25 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
25 | 26 | ||
26 | const video = await loadVideo(id, fetchType, userId) | 27 | const video = await loadVideo(id, fetchType, userId) |
27 | 28 | ||
28 | if (video === null) { | 29 | if (!video) { |
29 | res.fail({ | 30 | res.fail({ |
30 | status: HttpStatusCode.NOT_FOUND_404, | 31 | status: HttpStatusCode.NOT_FOUND_404, |
31 | message: 'Video not found' | 32 | message: 'Video not found' |
32 | }) | 33 | }) |
34 | |||
33 | return false | 35 | return false |
34 | } | 36 | } |
35 | 37 | ||
@@ -58,6 +60,8 @@ async function doesVideoExist (id: number | string, res: Response, fetchType: Vi | |||
58 | return true | 60 | return true |
59 | } | 61 | } |
60 | 62 | ||
63 | // --------------------------------------------------------------------------- | ||
64 | |||
61 | async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) { | 65 | async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) { |
62 | if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) { | 66 | if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) { |
63 | res.fail({ | 67 | res.fail({ |
@@ -70,6 +74,8 @@ async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | st | |||
70 | return true | 74 | return true |
71 | } | 75 | } |
72 | 76 | ||
77 | // --------------------------------------------------------------------------- | ||
78 | |||
73 | async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { | 79 | async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { |
74 | const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) | 80 | const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) |
75 | 81 | ||
@@ -95,32 +101,77 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc | |||
95 | return true | 101 | return true |
96 | } | 102 | } |
97 | 103 | ||
98 | async function checkCanSeeVideoIfPrivate (req: Request, res: Response, video: MVideo, authenticateInQuery = false) { | 104 | // --------------------------------------------------------------------------- |
99 | if (!video.requiresAuth()) return true | ||
100 | 105 | ||
101 | const videoWithRights = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) | 106 | async function checkCanSeeVideo (options: { |
107 | req: Request | ||
108 | res: Response | ||
109 | paramId: string | ||
110 | video: MVideo | ||
111 | authenticateInQuery?: boolean // default false | ||
112 | }) { | ||
113 | const { req, res, video, paramId, authenticateInQuery = false } = options | ||
114 | |||
115 | if (video.requiresAuth()) { | ||
116 | return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) | ||
117 | } | ||
102 | 118 | ||
103 | return checkCanSeePrivateVideo(req, res, videoWithRights, authenticateInQuery) | 119 | if (video.privacy === VideoPrivacy.UNLISTED) { |
104 | } | 120 | if (isUUIDValid(paramId)) return true |
105 | 121 | ||
106 | async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVideoWithRights, authenticateInQuery = false) { | 122 | return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) |
107 | await authenticatePromiseIfNeeded(req, res, authenticateInQuery) | 123 | } |
108 | 124 | ||
109 | const user = res.locals.oauth ? res.locals.oauth.token.User : null | 125 | if (video.privacy === VideoPrivacy.PUBLIC) return true |
126 | |||
127 | throw new Error('Fatal error when checking video right ' + video.url) | ||
128 | } | ||
110 | 129 | ||
111 | // Only the owner or a user that have blocklist rights can see the video | 130 | async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights, authenticateInQuery = false) { |
112 | if (!user || !user.canGetVideo(video)) { | 131 | const fail = () => { |
113 | res.fail({ | 132 | res.fail({ |
114 | status: HttpStatusCode.FORBIDDEN_403, | 133 | status: HttpStatusCode.FORBIDDEN_403, |
115 | message: 'Cannot fetch information of private/internal/blocklisted video' | 134 | message: 'Cannot fetch information of private/internal/blocked video' |
116 | }) | 135 | }) |
117 | 136 | ||
118 | return false | 137 | return false |
119 | } | 138 | } |
120 | 139 | ||
121 | return true | 140 | await authenticatePromiseIfNeeded(req, res, authenticateInQuery) |
141 | |||
142 | const user = res.locals.oauth?.token.User | ||
143 | if (!user) return fail() | ||
144 | |||
145 | const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId | ||
146 | ? video as MVideoWithRights | ||
147 | : await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id) | ||
148 | |||
149 | const privacy = videoWithRights.privacy | ||
150 | |||
151 | if (privacy === VideoPrivacy.INTERNAL) { | ||
152 | // We know we have a user | ||
153 | return true | ||
154 | } | ||
155 | |||
156 | const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id | ||
157 | if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { | ||
158 | if (isOwnedByUser && user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true | ||
159 | |||
160 | return fail() | ||
161 | } | ||
162 | |||
163 | if (videoWithRights.isBlacklisted()) { | ||
164 | if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true | ||
165 | |||
166 | return fail() | ||
167 | } | ||
168 | |||
169 | // Should not happen | ||
170 | return fail() | ||
122 | } | 171 | } |
123 | 172 | ||
173 | // --------------------------------------------------------------------------- | ||
174 | |||
124 | function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { | 175 | function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { |
125 | // Retrieve the user who did the request | 176 | // Retrieve the user who did the request |
126 | if (onlyOwned && video.isOwned() === false) { | 177 | if (onlyOwned && video.isOwned() === false) { |
@@ -146,6 +197,8 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: | |||
146 | return true | 197 | return true |
147 | } | 198 | } |
148 | 199 | ||
200 | // --------------------------------------------------------------------------- | ||
201 | |||
149 | async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) { | 202 | async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) { |
150 | if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { | 203 | if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { |
151 | res.fail({ | 204 | res.fail({ |
@@ -167,7 +220,6 @@ export { | |||
167 | doesVideoFileOfVideoExist, | 220 | doesVideoFileOfVideoExist, |
168 | 221 | ||
169 | checkUserCanManageVideo, | 222 | checkUserCanManageVideo, |
170 | checkCanSeeVideoIfPrivate, | 223 | checkCanSeeVideo, |
171 | checkCanSeePrivateVideo, | ||
172 | checkUserQuota | 224 | checkUserQuota |
173 | } | 225 | } |
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts index 441c6b4be..dfb8fefc5 100644 --- a/server/middlewares/validators/videos/video-captions.ts +++ b/server/middlewares/validators/videos/video-captions.ts | |||
@@ -7,7 +7,7 @@ import { logger } from '../../../helpers/logger' | |||
7 | import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants' | 7 | import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants' |
8 | import { | 8 | import { |
9 | areValidationErrors, | 9 | areValidationErrors, |
10 | checkCanSeeVideoIfPrivate, | 10 | checkCanSeeVideo, |
11 | checkUserCanManageVideo, | 11 | checkUserCanManageVideo, |
12 | doesVideoCaptionExist, | 12 | doesVideoCaptionExist, |
13 | doesVideoExist, | 13 | doesVideoExist, |
@@ -74,7 +74,7 @@ const listVideoCaptionsValidator = [ | |||
74 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return | 74 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return |
75 | 75 | ||
76 | const video = res.locals.onlyVideo | 76 | const video = res.locals.onlyVideo |
77 | if (!await checkCanSeeVideoIfPrivate(req, res, video)) return | 77 | if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return |
78 | 78 | ||
79 | return next() | 79 | return next() |
80 | } | 80 | } |
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index 698afdbd1..b22a4e3b7 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts | |||
@@ -10,7 +10,7 @@ import { Hooks } from '../../../lib/plugins/hooks' | |||
10 | import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video' | 10 | import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video' |
11 | import { | 11 | import { |
12 | areValidationErrors, | 12 | areValidationErrors, |
13 | checkCanSeeVideoIfPrivate, | 13 | checkCanSeeVideo, |
14 | doesVideoCommentExist, | 14 | doesVideoCommentExist, |
15 | doesVideoCommentThreadExist, | 15 | doesVideoCommentThreadExist, |
16 | doesVideoExist, | 16 | doesVideoExist, |
@@ -54,7 +54,7 @@ const listVideoCommentThreadsValidator = [ | |||
54 | if (areValidationErrors(req, res)) return | 54 | if (areValidationErrors(req, res)) return |
55 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return | 55 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return |
56 | 56 | ||
57 | if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return | 57 | if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return |
58 | 58 | ||
59 | return next() | 59 | return next() |
60 | } | 60 | } |
@@ -73,7 +73,7 @@ const listVideoThreadCommentsValidator = [ | |||
73 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return | 73 | if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return |
74 | if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return | 74 | if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return |
75 | 75 | ||
76 | if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return | 76 | if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return |
77 | 77 | ||
78 | return next() | 78 | return next() |
79 | } | 79 | } |
@@ -91,7 +91,7 @@ const addVideoCommentThreadValidator = [ | |||
91 | if (areValidationErrors(req, res)) return | 91 | if (areValidationErrors(req, res)) return |
92 | if (!await doesVideoExist(req.params.videoId, res)) return | 92 | if (!await doesVideoExist(req.params.videoId, res)) return |
93 | 93 | ||
94 | if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return | 94 | if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return |
95 | 95 | ||
96 | if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return | 96 | if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return |
97 | if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return | 97 | if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return |
@@ -113,7 +113,7 @@ const addVideoCommentReplyValidator = [ | |||
113 | if (areValidationErrors(req, res)) return | 113 | if (areValidationErrors(req, res)) return |
114 | if (!await doesVideoExist(req.params.videoId, res)) return | 114 | if (!await doesVideoExist(req.params.videoId, res)) return |
115 | 115 | ||
116 | if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return | 116 | if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return |
117 | 117 | ||
118 | if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return | 118 | if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return |
119 | if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return | 119 | if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return |
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts index 1a9736034..8b8eeedb6 100644 --- a/server/middlewares/validators/videos/video-rates.ts +++ b/server/middlewares/validators/videos/video-rates.ts | |||
@@ -8,7 +8,7 @@ import { isRatingValid } from '../../../helpers/custom-validators/video-rates' | |||
8 | import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' | 8 | import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' |
9 | import { logger } from '../../../helpers/logger' | 9 | import { logger } from '../../../helpers/logger' |
10 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | 10 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' |
11 | import { areValidationErrors, checkCanSeeVideoIfPrivate, doesVideoExist, isValidVideoIdParam } from '../shared' | 11 | import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' |
12 | 12 | ||
13 | const videoUpdateRateValidator = [ | 13 | const videoUpdateRateValidator = [ |
14 | isValidVideoIdParam('id'), | 14 | isValidVideoIdParam('id'), |
@@ -21,7 +21,7 @@ const videoUpdateRateValidator = [ | |||
21 | if (areValidationErrors(req, res)) return | 21 | if (areValidationErrors(req, res)) return |
22 | if (!await doesVideoExist(req.params.id, res)) return | 22 | if (!await doesVideoExist(req.params.id, res)) return |
23 | 23 | ||
24 | if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.videoAll)) return | 24 | if (!await checkCanSeeVideo({ req, res, paramId: req.params.id, video: res.locals.videoAll })) return |
25 | 25 | ||
26 | return next() | 26 | return next() |
27 | } | 27 | } |
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index c75c3640b..c6d31f8f0 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -7,14 +7,13 @@ import { getServerActor } from '@server/models/application/application' | |||
7 | import { ExpressPromiseHandler } from '@server/types/express-handler' | 7 | import { ExpressPromiseHandler } from '@server/types/express-handler' |
8 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' | 8 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' |
9 | import { getAllPrivacies } from '@shared/core-utils' | 9 | import { getAllPrivacies } from '@shared/core-utils' |
10 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoPrivacy } from '@shared/models' | 10 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models' |
11 | import { | 11 | import { |
12 | exists, | 12 | exists, |
13 | isBooleanValid, | 13 | isBooleanValid, |
14 | isDateValid, | 14 | isDateValid, |
15 | isFileValid, | 15 | isFileValid, |
16 | isIdValid, | 16 | isIdValid, |
17 | isUUIDValid, | ||
18 | toArray, | 17 | toArray, |
19 | toBooleanOrNull, | 18 | toBooleanOrNull, |
20 | toIntOrNull, | 19 | toIntOrNull, |
@@ -50,7 +49,7 @@ import { Hooks } from '../../../lib/plugins/hooks' | |||
50 | import { VideoModel } from '../../../models/video/video' | 49 | import { VideoModel } from '../../../models/video/video' |
51 | import { | 50 | import { |
52 | areValidationErrors, | 51 | areValidationErrors, |
53 | checkCanSeePrivateVideo, | 52 | checkCanSeeVideo, |
54 | checkUserCanManageVideo, | 53 | checkUserCanManageVideo, |
55 | checkUserQuota, | 54 | checkUserQuota, |
56 | doesVideoChannelOfAccountExist, | 55 | doesVideoChannelOfAccountExist, |
@@ -297,28 +296,9 @@ const videosCustomGetValidator = ( | |||
297 | 296 | ||
298 | const video = getVideoWithAttributes(res) as MVideoFullLight | 297 | const video = getVideoWithAttributes(res) as MVideoFullLight |
299 | 298 | ||
300 | // Video private or blacklisted | 299 | if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return |
301 | if (video.requiresAuth()) { | ||
302 | if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) { | ||
303 | return next() | ||
304 | } | ||
305 | 300 | ||
306 | return | 301 | return next() |
307 | } | ||
308 | |||
309 | // Video is public, anyone can access it | ||
310 | if (video.privacy === VideoPrivacy.PUBLIC) return next() | ||
311 | |||
312 | // Video is unlisted, check we used the uuid to fetch it | ||
313 | if (video.privacy === VideoPrivacy.UNLISTED) { | ||
314 | if (isUUIDValid(req.params.id)) return next() | ||
315 | |||
316 | // Don't leak this unlisted video | ||
317 | return res.fail({ | ||
318 | status: HttpStatusCode.NOT_FOUND_404, | ||
319 | message: 'Video not found' | ||
320 | }) | ||
321 | } | ||
322 | } | 302 | } |
323 | ] | 303 | ] |
324 | } | 304 | } |