From 3545e72c686ff1725bbdfd8d16d693e2f4aa75a3 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 12 Oct 2022 16:09:02 +0200 Subject: Put private videos under a specific subdirectory --- server/middlewares/auth.ts | 8 +- server/middlewares/validators/index.ts | 7 +- server/middlewares/validators/shared/videos.ts | 54 +++++++--- server/middlewares/validators/static.ts | 131 +++++++++++++++++++++++++ server/middlewares/validators/videos/videos.ts | 33 +++++-- 5 files changed, 204 insertions(+), 29 deletions(-) create mode 100644 server/middlewares/validators/static.ts (limited to 'server/middlewares') 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' import { logger } from '../helpers/logger' import { handleOAuthAuthenticate } from '../lib/auth/oauth' -function authenticate (req: express.Request, res: express.Response, next: express.NextFunction, authenticateInQuery = false) { - handleOAuthAuthenticate(req, res, authenticateInQuery) +function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { + handleOAuthAuthenticate(req, res) .then((token: any) => { res.locals.oauth = { token } res.locals.authenticated = true @@ -47,7 +47,7 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) { .catch(err => logger.error('Cannot get access token.', { err })) } -function authenticatePromise (req: express.Request, res: express.Response, authenticateInQuery = false) { +function authenticatePromise (req: express.Request, res: express.Response) { return new Promise(resolve => { // Already authenticated? (or tried to) if (res.locals.oauth?.token.User) return resolve() @@ -59,7 +59,7 @@ function authenticatePromise (req: express.Request, res: express.Response, authe }) } - authenticate(req, res, () => resolve(), authenticateInQuery) + authenticate(req, res, () => resolve()) }) } 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 @@ -export * from './activitypub' -export * from './videos' export * from './abuse' export * from './account' +export * from './activitypub' export * from './actor-image' export * from './blocklist' export * from './bulk' @@ -10,8 +9,8 @@ export * from './express' export * from './feeds' export * from './follows' export * from './jobs' -export * from './metrics' export * from './logs' +export * from './metrics' export * from './oembed' export * from './pagination' export * from './plugins' @@ -19,9 +18,11 @@ export * from './redundancy' export * from './search' export * from './server' export * from './sort' +export * from './static' export * from './themes' export * from './user-history' export * from './user-notifications' export * from './user-subscriptions' export * from './users' +export * from './videos' 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 @@ import { Request, Response } from 'express' -import { isUUIDValid } from '@server/helpers/custom-validators/misc' import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' import { isAbleToUploadVideo } from '@server/lib/user' +import { VideoTokensManager } from '@server/lib/video-tokens-manager' import { authenticatePromise } from '@server/middlewares/auth' import { VideoModel } from '@server/models/video/video' import { VideoChannelModel } from '@server/models/video/video-channel' @@ -108,26 +108,21 @@ async function checkCanSeeVideo (options: { res: Response paramId: string video: MVideo - authenticateInQuery?: boolean // default false }) { - const { req, res, video, paramId, authenticateInQuery = false } = options + const { req, res, video, paramId } = options - if (video.requiresAuth()) { - return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) + if (video.requiresAuth(paramId)) { + return checkCanSeeAuthVideo(req, res, video) } - if (video.privacy === VideoPrivacy.UNLISTED) { - if (isUUIDValid(paramId)) return true - - return checkCanSeeAuthVideo(req, res, video, authenticateInQuery) + if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { + return true } - if (video.privacy === VideoPrivacy.PUBLIC) return true - - throw new Error('Fatal error when checking video right ' + video.url) + throw new Error('Unknown video privacy when checking video right ' + video.url) } -async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights, authenticateInQuery = false) { +async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { const fail = () => { res.fail({ status: HttpStatusCode.FORBIDDEN_403, @@ -137,7 +132,7 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI return false } - await authenticatePromise(req, res, authenticateInQuery) + await authenticatePromise(req, res) const user = res.locals.oauth?.token.User if (!user) return fail() @@ -173,6 +168,36 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI // --------------------------------------------------------------------------- +async function checkCanAccessVideoStaticFiles (options: { + video: MVideo + req: Request + res: Response + paramId: string +}) { + const { video, req, res, paramId } = options + + if (res.locals.oauth?.token.User) { + return checkCanSeeVideo(options) + } + + if (!video.requiresAuth(paramId)) return true + + const videoFileToken = req.query.videoFileToken + if (!videoFileToken) { + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { + return true + } + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false +} + +// --------------------------------------------------------------------------- + function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { // Retrieve the user who did the request if (onlyOwned && video.isOwned() === false) { @@ -220,6 +245,7 @@ export { doesVideoExist, doesVideoFileOfVideoExist, + checkCanAccessVideoStaticFiles, checkUserCanManageVideo, checkCanSeeVideo, 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 @@ +import express from 'express' +import { query } from 'express-validator' +import LRUCache from 'lru-cache' +import { basename, dirname } from 'path' +import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc' +import { logger } from '@server/helpers/logger' +import { LRU_CACHE } from '@server/initializers/constants' +import { VideoModel } from '@server/models/video/video' +import { VideoFileModel } from '@server/models/video/video-file' +import { HttpStatusCode } from '@shared/models' +import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' + +const staticFileTokenBypass = new LRUCache({ + max: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.MAX_SIZE, + ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL +}) + +const ensureCanAccessVideoPrivateWebTorrentFiles = [ + query('videoFileToken').optional().custom(exists), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const token = extractTokenOrDie(req, res) + if (!token) return + + const cacheKey = token + '-' + req.originalUrl + + if (staticFileTokenBypass.has(cacheKey)) { + const allowedFromCache = staticFileTokenBypass.get(cacheKey) + + if (allowedFromCache === true) return next() + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const allowed = await isWebTorrentAllowed(req, res) + + staticFileTokenBypass.set(cacheKey, allowed) + + if (allowed !== true) return + + return next() + } +] + +const ensureCanAccessPrivateVideoHLSFiles = [ + query('videoFileToken').optional().custom(exists), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + const videoUUID = basename(dirname(req.originalUrl)) + + if (!isUUIDValid(videoUUID)) { + logger.debug('Path does not contain valid video UUID to serve static file %s', req.originalUrl) + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const token = extractTokenOrDie(req, res) + if (!token) return + + const cacheKey = token + '-' + videoUUID + + if (staticFileTokenBypass.has(cacheKey)) { + const allowedFromCache = staticFileTokenBypass.get(cacheKey) + + if (allowedFromCache === true) return next() + + return res.sendStatus(HttpStatusCode.FORBIDDEN_403) + } + + const allowed = await isHLSAllowed(req, res, videoUUID) + + staticFileTokenBypass.set(cacheKey, allowed) + + if (allowed !== true) return + + return next() + } +] + +export { + ensureCanAccessVideoPrivateWebTorrentFiles, + ensureCanAccessPrivateVideoHLSFiles +} + +// --------------------------------------------------------------------------- + +async function isWebTorrentAllowed (req: express.Request, res: express.Response) { + const filename = basename(req.path) + + const file = await VideoFileModel.loadWithVideoByFilename(filename) + if (!file) { + logger.debug('Unknown static file %s to serve', req.originalUrl, { filename }) + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + const video = file.getVideo() + + return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) +} + +async function isHLSAllowed (req: express.Request, res: express.Response, videoUUID: string) { + const video = await VideoModel.load(videoUUID) + + if (!video) { + logger.debug('Unknown static file %s to serve', req.originalUrl, { videoUUID }) + + res.sendStatus(HttpStatusCode.FORBIDDEN_403) + return false + } + + return checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid }) +} + +function extractTokenOrDie (req: express.Request, res: express.Response) { + const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken + + if (!token) { + return res.fail({ + message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', + status: HttpStatusCode.FORBIDDEN_403 + }) + } + + return token +} 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' import { ExpressPromiseHandler } from '@server/types/express-handler' import { MUserAccountId, MVideoFullLight } from '@server/types/models' import { arrayify, getAllPrivacies } from '@shared/core-utils' -import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models' +import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' import { exists, isBooleanValid, @@ -48,6 +48,7 @@ import { Hooks } from '../../../lib/plugins/hooks' import { VideoModel } from '../../../models/video/video' import { areValidationErrors, + checkCanAccessVideoStaticFiles, checkCanSeeVideo, checkUserCanManageVideo, checkUserQuota, @@ -232,6 +233,11 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) + const video = getVideoWithAttributes(res) + if (req.body.privacy && video.isLive && video.state !== VideoState.WAITING_FOR_LIVE) { + return res.fail({ message: 'Cannot update privacy of a live that has already started' }) + } + // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User 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 }) } -const videosCustomGetValidator = ( - fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes', - authenticateInQuery = false -) => { +const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes') => { return [ isValidVideoIdParam('id'), @@ -287,7 +290,7 @@ const videosCustomGetValidator = ( const video = getVideoWithAttributes(res) as MVideoFullLight - if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return + if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return return next() } @@ -295,7 +298,6 @@ const videosCustomGetValidator = ( } const videosGetValidator = videosCustomGetValidator('all') -const videosDownloadValidator = videosCustomGetValidator('all', true) const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ isValidVideoIdParam('id'), @@ -311,6 +313,21 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ } ]) +const videosDownloadValidator = [ + isValidVideoIdParam('id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.id, res, 'all')) return + + const video = getVideoWithAttributes(res) + + if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return + + return next() + } +] + const videosRemoveValidator = [ isValidVideoIdParam('id'), @@ -372,7 +389,7 @@ function getCommonVideoEditAttributes () { .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'), body('privacy') .optional() - .customSanitizer(toValueOrNull) + .customSanitizer(toIntOrNull) .custom(isVideoPrivacyValid), body('description') .optional() -- cgit v1.2.3