diff options
author | Chocobozzz <me@florianbigard.com> | 2022-10-12 16:09:02 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-10-24 14:48:24 +0200 |
commit | 3545e72c686ff1725bbdfd8d16d693e2f4aa75a3 (patch) | |
tree | e7f1d12ef5dae1e1142c3a8d0b681c1dbbb0de10 /server/middlewares | |
parent | 38a3ccc7f8ad0ea94362b58c732af7c387ab46be (diff) | |
download | PeerTube-3545e72c686ff1725bbdfd8d16d693e2f4aa75a3.tar.gz PeerTube-3545e72c686ff1725bbdfd8d16d693e2f4aa75a3.tar.zst PeerTube-3545e72c686ff1725bbdfd8d16d693e2f4aa75a3.zip |
Put private videos under a specific subdirectory
Diffstat (limited to 'server/middlewares')
-rw-r--r-- | server/middlewares/auth.ts | 8 | ||||
-rw-r--r-- | server/middlewares/validators/index.ts | 7 | ||||
-rw-r--r-- | server/middlewares/validators/shared/videos.ts | 54 | ||||
-rw-r--r-- | server/middlewares/validators/static.ts | 131 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 33 |
5 files changed, 204 insertions, 29 deletions
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts index 904d47efd..e6025c8ce 100644 --- a/server/middlewares/auth.ts +++ b/server/middlewares/auth.ts | |||
@@ -5,8 +5,8 @@ import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | |||
5 | import { logger } from '../helpers/logger' | 5 | import { logger } from '../helpers/logger' |
6 | import { handleOAuthAuthenticate } from '../lib/auth/oauth' | 6 | import { handleOAuthAuthenticate } from '../lib/auth/oauth' |
7 | 7 | ||
8 | function authenticate (req: express.Request, res: express.Response, next: express.NextFunction, authenticateInQuery = false) { | 8 | function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { |
9 | handleOAuthAuthenticate(req, res, authenticateInQuery) | 9 | handleOAuthAuthenticate(req, res) |
10 | .then((token: any) => { | 10 | .then((token: any) => { |
11 | res.locals.oauth = { token } | 11 | res.locals.oauth = { token } |
12 | res.locals.authenticated = true | 12 | res.locals.authenticated = true |
@@ -47,7 +47,7 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) { | |||
47 | .catch(err => logger.error('Cannot get access token.', { err })) | 47 | .catch(err => logger.error('Cannot get access token.', { err })) |
48 | } | 48 | } |
49 | 49 | ||
50 | function authenticatePromise (req: express.Request, res: express.Response, authenticateInQuery = false) { | 50 | function authenticatePromise (req: express.Request, res: express.Response) { |
51 | return new Promise<void>(resolve => { | 51 | return new Promise<void>(resolve => { |
52 | // Already authenticated? (or tried to) | 52 | // Already authenticated? (or tried to) |
53 | if (res.locals.oauth?.token.User) return resolve() | 53 | if (res.locals.oauth?.token.User) return resolve() |
@@ -59,7 +59,7 @@ function authenticatePromise (req: express.Request, res: express.Response, authe | |||
59 | }) | 59 | }) |
60 | } | 60 | } |
61 | 61 | ||
62 | authenticate(req, res, () => resolve(), authenticateInQuery) | 62 | authenticate(req, res, () => resolve()) |
63 | }) | 63 | }) |
64 | } | 64 | } |
65 | 65 | ||
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index ffadb3b49..899da229a 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts | |||
@@ -1,7 +1,6 @@ | |||
1 | export * from './activitypub' | ||
2 | export * from './videos' | ||
3 | export * from './abuse' | 1 | export * from './abuse' |
4 | export * from './account' | 2 | export * from './account' |
3 | export * from './activitypub' | ||
5 | export * from './actor-image' | 4 | export * from './actor-image' |
6 | export * from './blocklist' | 5 | export * from './blocklist' |
7 | export * from './bulk' | 6 | export * from './bulk' |
@@ -10,8 +9,8 @@ export * from './express' | |||
10 | export * from './feeds' | 9 | export * from './feeds' |
11 | export * from './follows' | 10 | export * from './follows' |
12 | export * from './jobs' | 11 | export * from './jobs' |
13 | export * from './metrics' | ||
14 | export * from './logs' | 12 | export * from './logs' |
13 | export * from './metrics' | ||
15 | export * from './oembed' | 14 | export * from './oembed' |
16 | export * from './pagination' | 15 | export * from './pagination' |
17 | export * from './plugins' | 16 | export * from './plugins' |
@@ -19,9 +18,11 @@ export * from './redundancy' | |||
19 | export * from './search' | 18 | export * from './search' |
20 | export * from './server' | 19 | export * from './server' |
21 | export * from './sort' | 20 | export * from './sort' |
21 | export * from './static' | ||
22 | export * from './themes' | 22 | export * from './themes' |
23 | export * from './user-history' | 23 | export * from './user-history' |
24 | export * from './user-notifications' | 24 | export * from './user-notifications' |
25 | export * from './user-subscriptions' | 25 | export * from './user-subscriptions' |
26 | export * from './users' | 26 | export * from './users' |
27 | export * from './videos' | ||
27 | export * from './webfinger' | 28 | export * from './webfinger' |
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index e3a98c58f..c29751eca 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Request, Response } from 'express' | 1 | import { Request, Response } from 'express' |
2 | import { isUUIDValid } from '@server/helpers/custom-validators/misc' | ||
3 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' | 2 | import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' |
4 | import { isAbleToUploadVideo } from '@server/lib/user' | 3 | import { isAbleToUploadVideo } from '@server/lib/user' |
4 | import { VideoTokensManager } from '@server/lib/video-tokens-manager' | ||
5 | import { authenticatePromise } from '@server/middlewares/auth' | 5 | import { authenticatePromise } from '@server/middlewares/auth' |
6 | import { VideoModel } from '@server/models/video/video' | 6 | import { VideoModel } from '@server/models/video/video' |
7 | import { VideoChannelModel } from '@server/models/video/video-channel' | 7 | import { VideoChannelModel } from '@server/models/video/video-channel' |
@@ -108,26 +108,21 @@ async function checkCanSeeVideo (options: { | |||
108 | res: Response | 108 | res: Response |
109 | paramId: string | 109 | paramId: string |
110 | video: MVideo | 110 | video: MVideo |
111 | authenticateInQuery?: boolean // default false | ||
112 | }) { | 111 | }) { |
113 | const { req, res, video, paramId, authenticateInQuery = false } = options | 112 | const { req, res, video, paramId } = options |
114 | 113 | ||
115 | if (video.requiresAuth()) { | 114 | if (video.requiresAuth(paramId)) { |
116 | return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) | 115 | return checkCanSeeAuthVideo(req, res, video) |
117 | } | 116 | } |
118 | 117 | ||
119 | if (video.privacy === VideoPrivacy.UNLISTED) { | 118 | if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { |
120 | if (isUUIDValid(paramId)) return true | 119 | return true |
121 | |||
122 | return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) | ||
123 | } | 120 | } |
124 | 121 | ||
125 | if (video.privacy === VideoPrivacy.PUBLIC) return true | 122 | throw new Error('Unknown video privacy when checking video right ' + video.url) |
126 | |||
127 | throw new Error('Fatal error when checking video right ' + video.url) | ||
128 | } | 123 | } |
129 | 124 | ||
130 | async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights, authenticateInQuery = false) { | 125 | async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { |
131 | const fail = () => { | 126 | const fail = () => { |
132 | res.fail({ | 127 | res.fail({ |
133 | status: HttpStatusCode.FORBIDDEN_403, | 128 | status: HttpStatusCode.FORBIDDEN_403, |
@@ -137,7 +132,7 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
137 | return false | 132 | return false |
138 | } | 133 | } |
139 | 134 | ||
140 | await authenticatePromise(req, res, authenticateInQuery) | 135 | await authenticatePromise(req, res) |
141 | 136 | ||
142 | const user = res.locals.oauth?.token.User | 137 | const user = res.locals.oauth?.token.User |
143 | if (!user) return fail() | 138 | if (!user) return fail() |
@@ -173,6 +168,36 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI | |||
173 | 168 | ||
174 | // --------------------------------------------------------------------------- | 169 | // --------------------------------------------------------------------------- |
175 | 170 | ||
171 | async function checkCanAccessVideoStaticFiles (options: { | ||
172 | video: MVideo | ||
173 | req: Request | ||
174 | res: Response | ||
175 | paramId: string | ||
176 | }) { | ||
177 | const { video, req, res, paramId } = options | ||
178 | |||
179 | if (res.locals.oauth?.token.User) { | ||
180 | return checkCanSeeVideo(options) | ||
181 | } | ||
182 | |||
183 | if (!video.requiresAuth(paramId)) return true | ||
184 | |||
185 | const videoFileToken = req.query.videoFileToken | ||
186 | if (!videoFileToken) { | ||
187 | res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
188 | return false | ||
189 | } | ||
190 | |||
191 | if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { | ||
192 | return true | ||
193 | } | ||
194 | |||
195 | res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
196 | return false | ||
197 | } | ||
198 | |||
199 | // --------------------------------------------------------------------------- | ||
200 | |||
176 | function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { | 201 | function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { |
177 | // Retrieve the user who did the request | 202 | // Retrieve the user who did the request |
178 | if (onlyOwned && video.isOwned() === false) { | 203 | if (onlyOwned && video.isOwned() === false) { |
@@ -220,6 +245,7 @@ export { | |||
220 | doesVideoExist, | 245 | doesVideoExist, |
221 | doesVideoFileOfVideoExist, | 246 | doesVideoFileOfVideoExist, |
222 | 247 | ||
248 | checkCanAccessVideoStaticFiles, | ||
223 | checkUserCanManageVideo, | 249 | checkUserCanManageVideo, |
224 | checkCanSeeVideo, | 250 | checkCanSeeVideo, |
225 | checkUserQuota | 251 | checkUserQuota |
diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts new file mode 100644 index 000000000..ff9e6ae6e --- /dev/null +++ b/server/middlewares/validators/static.ts | |||
@@ -0,0 +1,131 @@ | |||
1 | import express from 'express' | ||
2 | import { query } from 'express-validator' | ||
3 | import LRUCache from 'lru-cache' | ||
4 | import { basename, dirname } from 'path' | ||
5 | import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc' | ||
6 | import { logger } from '@server/helpers/logger' | ||
7 | import { LRU_CACHE } from '@server/initializers/constants' | ||
8 | import { VideoModel } from '@server/models/video/video' | ||
9 | import { VideoFileModel } from '@server/models/video/video-file' | ||
10 | import { HttpStatusCode } from '@shared/models' | ||
11 | import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' | ||
12 | |||
13 | const staticFileTokenBypass = new LRUCache<string, boolean>({ | ||
14 | max: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.MAX_SIZE, | ||
15 | ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL | ||
16 | }) | ||
17 | |||
18 | const ensureCanAccessVideoPrivateWebTorrentFiles = [ | ||
19 | query('videoFileToken').optional().custom(exists), | ||
20 | |||
21 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
22 | if (areValidationErrors(req, res)) return | ||
23 | |||
24 | const token = extractTokenOrDie(req, res) | ||
25 | if (!token) return | ||
26 | |||
27 | const cacheKey = token + '-' + req.originalUrl | ||
28 | |||
29 | if (staticFileTokenBypass.has(cacheKey)) { | ||
30 | const allowedFromCache = staticFileTokenBypass.get(cacheKey) | ||
31 | |||
32 | if (allowedFromCache === true) return next() | ||
33 | |||
34 | return res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
35 | } | ||
36 | |||
37 | const allowed = await isWebTorrentAllowed(req, res) | ||
38 | |||
39 | staticFileTokenBypass.set(cacheKey, allowed) | ||
40 | |||
41 | if (allowed !== true) return | ||
42 | |||
43 | return next() | ||
44 | } | ||
45 | ] | ||
46 | |||
47 | const ensureCanAccessPrivateVideoHLSFiles = [ | ||
48 | query('videoFileToken').optional().custom(exists), | ||
49 | |||
50 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
51 | if (areValidationErrors(req, res)) return | ||
52 | |||
53 | const videoUUID = basename(dirname(req.originalUrl)) | ||
54 | |||
55 | if (!isUUIDValid(videoUUID)) { | ||
56 | logger.debug('Path does not contain valid video UUID to serve static file %s', req.originalUrl) | ||
57 | |||
58 | return res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
59 | } | ||
60 | |||
61 | const token = extractTokenOrDie(req, res) | ||
62 | if (!token) return | ||
63 | |||
64 | const cacheKey = token + '-' + videoUUID | ||
65 | |||
66 | if (staticFileTokenBypass.has(cacheKey)) { | ||
67 | const allowedFromCache = staticFileTokenBypass.get(cacheKey) | ||
68 | |||
69 | if (allowedFromCache === true) return next() | ||
70 | |||
71 | return res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
72 | } | ||
73 | |||
74 | const allowed = await isHLSAllowed(req, res, videoUUID) | ||
75 | |||
76 | staticFileTokenBypass.set(cacheKey, allowed) | ||
77 | |||
78 | if (allowed !== true) return | ||
79 | |||
80 | return next() | ||
81 | } | ||
82 | ] | ||
83 | |||
84 | export { | ||
85 | ensureCanAccessVideoPrivateWebTorrentFiles, | ||
86 | ensureCanAccessPrivateVideoHLSFiles | ||
87 | } | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | async function isWebTorrentAllowed (req: express.Request, res: express.Response) { | ||
92 | const filename = basename(req.path) | ||
93 | |||
94 | const file = await VideoFileModel.loadWithVideoByFilename(filename) | ||
95 | if (!file) { | ||
96 | logger.debug('Unknown static file %s to serve', req.originalUrl, { filename }) | ||
97 | |||
98 | res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
99 | return false | ||
100 | } | ||
101 | |||
102 | const video = file.getVideo() | ||
103 | |||
104 | return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) | ||
105 | } | ||
106 | |||
107 | async function isHLSAllowed (req: express.Request, res: express.Response, videoUUID: string) { | ||
108 | const video = await VideoModel.load(videoUUID) | ||
109 | |||
110 | if (!video) { | ||
111 | logger.debug('Unknown static file %s to serve', req.originalUrl, { videoUUID }) | ||
112 | |||
113 | res.sendStatus(HttpStatusCode.FORBIDDEN_403) | ||
114 | return false | ||
115 | } | ||
116 | |||
117 | return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) | ||
118 | } | ||
119 | |||
120 | function extractTokenOrDie (req: express.Request, res: express.Response) { | ||
121 | const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken | ||
122 | |||
123 | if (!token) { | ||
124 | return res.fail({ | ||
125 | message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', | ||
126 | status: HttpStatusCode.FORBIDDEN_403 | ||
127 | }) | ||
128 | } | ||
129 | |||
130 | return token | ||
131 | } | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 7fd2b03d1..e29eb4a32 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -7,7 +7,7 @@ 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 { arrayify, getAllPrivacies } from '@shared/core-utils' | 9 | import { arrayify, getAllPrivacies } from '@shared/core-utils' |
10 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models' | 10 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' |
11 | import { | 11 | import { |
12 | exists, | 12 | exists, |
13 | isBooleanValid, | 13 | isBooleanValid, |
@@ -48,6 +48,7 @@ import { Hooks } from '../../../lib/plugins/hooks' | |||
48 | import { VideoModel } from '../../../models/video/video' | 48 | import { VideoModel } from '../../../models/video/video' |
49 | import { | 49 | import { |
50 | areValidationErrors, | 50 | areValidationErrors, |
51 | checkCanAccessVideoStaticFiles, | ||
51 | checkCanSeeVideo, | 52 | checkCanSeeVideo, |
52 | checkUserCanManageVideo, | 53 | checkUserCanManageVideo, |
53 | checkUserQuota, | 54 | checkUserQuota, |
@@ -232,6 +233,11 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ | |||
232 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) | 233 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) |
233 | if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) | 234 | if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) |
234 | 235 | ||
236 | const video = getVideoWithAttributes(res) | ||
237 | if (req.body.privacy && video.isLive && video.state !== VideoState.WAITING_FOR_LIVE) { | ||
238 | return res.fail({ message: 'Cannot update privacy of a live that has already started' }) | ||
239 | } | ||
240 | |||
235 | // Check if the user who did the request is able to update the video | 241 | // Check if the user who did the request is able to update the video |
236 | const user = res.locals.oauth.token.User | 242 | const user = res.locals.oauth.token.User |
237 | if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) | 243 | if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req) |
@@ -271,10 +277,7 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R | |||
271 | }) | 277 | }) |
272 | } | 278 | } |
273 | 279 | ||
274 | const videosCustomGetValidator = ( | 280 | const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes') => { |
275 | fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes', | ||
276 | authenticateInQuery = false | ||
277 | ) => { | ||
278 | return [ | 281 | return [ |
279 | isValidVideoIdParam('id'), | 282 | isValidVideoIdParam('id'), |
280 | 283 | ||
@@ -287,7 +290,7 @@ const videosCustomGetValidator = ( | |||
287 | 290 | ||
288 | const video = getVideoWithAttributes(res) as MVideoFullLight | 291 | const video = getVideoWithAttributes(res) as MVideoFullLight |
289 | 292 | ||
290 | if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return | 293 | if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return |
291 | 294 | ||
292 | return next() | 295 | return next() |
293 | } | 296 | } |
@@ -295,7 +298,6 @@ const videosCustomGetValidator = ( | |||
295 | } | 298 | } |
296 | 299 | ||
297 | const videosGetValidator = videosCustomGetValidator('all') | 300 | const videosGetValidator = videosCustomGetValidator('all') |
298 | const videosDownloadValidator = videosCustomGetValidator('all', true) | ||
299 | 301 | ||
300 | const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ | 302 | const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ |
301 | isValidVideoIdParam('id'), | 303 | isValidVideoIdParam('id'), |
@@ -311,6 +313,21 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ | |||
311 | } | 313 | } |
312 | ]) | 314 | ]) |
313 | 315 | ||
316 | const videosDownloadValidator = [ | ||
317 | isValidVideoIdParam('id'), | ||
318 | |||
319 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
320 | if (areValidationErrors(req, res)) return | ||
321 | if (!await doesVideoExist(req.params.id, res, 'all')) return | ||
322 | |||
323 | const video = getVideoWithAttributes(res) | ||
324 | |||
325 | if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return | ||
326 | |||
327 | return next() | ||
328 | } | ||
329 | ] | ||
330 | |||
314 | const videosRemoveValidator = [ | 331 | const videosRemoveValidator = [ |
315 | isValidVideoIdParam('id'), | 332 | isValidVideoIdParam('id'), |
316 | 333 | ||
@@ -372,7 +389,7 @@ function getCommonVideoEditAttributes () { | |||
372 | .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'), | 389 | .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'), |
373 | body('privacy') | 390 | body('privacy') |
374 | .optional() | 391 | .optional() |
375 | .customSanitizer(toValueOrNull) | 392 | .customSanitizer(toIntOrNull) |
376 | .custom(isVideoPrivacyValid), | 393 | .custom(isVideoPrivacyValid), |
377 | body('description') | 394 | body('description') |
378 | .optional() | 395 | .optional() |