Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
import { ParsedDraftSignature } from '@misskey-dev/node-http-message-signatures'
import { ActivityDelete, ActivityPubSignature, HttpStatusCode } from '@peertube/peertube-models'
import { isActorDeleteActivityValid } from '@server/helpers/custom-validators/activitypub/actor.js'
import { isHTTPSignatureVerified, parseHTTPSignature } from '@server/helpers/peertube-crypto.js'
import { getAPId } from '@server/lib/activitypub/activity.js'
import { wrapWithSpanAndContext } from '@server/lib/opentelemetry/tracing.js'
import { NextFunction, Request, Response } from 'express'
import { logger } from '../helpers/logger.js'
import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants.js'
import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../lib/activitypub/actors/index.js'
export async function checkSignature (req: Request, res: Response, next: NextFunction) {
try {
const httpSignatureChecked = await checkHttpSignature(req, res)
if (httpSignatureChecked !== true) return
const actor = res.locals.signature.actor
// Forwarded activity
const bodyActor = req.body.actor
const bodyActorId = getAPId(bodyActor)
if (bodyActorId && bodyActorId !== actor.url) {
const jsonLDSignatureChecked = await checkJsonLDSignature(req, res)
if (jsonLDSignatureChecked !== true) return
}
return next()
} catch (err) {
const activity: ActivityDelete = req.body
if (isActorDeleteActivityValid(activity) && activity.object === activity.actor) {
logger.debug('Handling signature error on actor delete activity', { err })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
logger.warn('Error in ActivityPub signature checker.', { err })
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'ActivityPub signature could not be checked'
})
}
}
export function executeIfActivityPub (req: Request, res: Response, next: NextFunction) {
const accepted = req.accepts(ACCEPT_HEADERS)
if (accepted === false || ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS.includes(accepted) === false) {
// Bypass this route
return next('route')
}
logger.debug('ActivityPub request for %s.', req.url)
return next()
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkHttpSignature (req: Request, res: Response) {
return wrapWithSpanAndContext('peertube.activitypub.checkHTTPSignature', async () => {
// Compatibility with http-signature < v1.3
const sig = req.headers['signature'] as string
if (sig && sig.startsWith('Signature ') === true) req.headers['signature'] = sig.replace(/^Signature /, '')
let parsed: ParsedDraftSignature
try {
parsed = parseHTTPSignature(req, HTTP_SIGNATURE.CLOCK_SKEW_SECONDS)
} catch (err) {
logger.warn('Invalid signature because of exception in signature parser', { reqBody: req.body, err })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot parse HTTP signature')
})
return false
}
const keyId = parsed.value?.keyId
if (!keyId) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Invalid key ID'),
data: {
keyId
}
})
return false
}
logger.debug('Checking HTTP signature of actor %s...', keyId)
let [ actorUrl ] = keyId.split('#')
if (actorUrl.startsWith('acct:')) {
actorUrl = await loadActorUrlOrGetFromWebfinger(actorUrl.replace(/^acct:/, ''))
}
const actor = await getOrCreateAPActor(actorUrl)
const verified = await isHTTPSignatureVerified(parsed, actor)
if (verified !== true) {
logger.warn('Signature from %s is invalid', actorUrl, { parsed })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Invalid signature'),
data: {
actorUrl
}
})
return false
}
res.locals.signature = { actor }
return true
})
}
async function checkJsonLDSignature (req: Request, res: Response) {
// Lazy load the module as it's quite big with json.ld dependency
const { compactJSONLDAndCheckSignature } = await import('../helpers/peertube-jsonld.js')
return wrapWithSpanAndContext('peertube.activitypub.JSONLDSignature', async () => {
const signatureObject: ActivityPubSignature = req.body.signature
if (!signatureObject?.creator) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Object and creator signature do not match'
})
return false
}
const [ creator ] = signatureObject.creator.split('#')
logger.debug('Checking JsonLD signature of actor %s...', creator)
const actor = await getOrCreateAPActor(creator)
const verified = await compactJSONLDAndCheckSignature(actor, req)
if (verified !== true) {
logger.warn('Signature not verified.', req.body)
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Signature could not be verified'
})
return false
}
res.locals.signature = { actor }
return true
})
}

View File

@@ -0,0 +1,48 @@
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { NextFunction, Request, RequestHandler, Response } from 'express'
import { ValidationChain } from 'express-validator'
import { retryTransactionWrapper } from '../helpers/database-utils.js'
// Syntactic sugar to avoid try/catch in express controllers/middlewares
export type RequestPromiseHandler = ValidationChain | ExpressPromiseHandler
function asyncMiddleware (fun: RequestPromiseHandler | RequestPromiseHandler[]) {
return async (req: Request, res: Response, next: NextFunction) => {
if (Array.isArray(fun) !== true) {
return Promise.resolve((fun as RequestHandler)(req, res, next))
.catch(err => next(err))
}
try {
for (const f of fun) {
await new Promise<void>((resolve, reject) => {
return asyncMiddleware(f)(req, res, err => {
if (err) return reject(err)
return resolve()
})
})
}
next()
} catch (err) {
next(err)
}
}
}
function asyncRetryTransactionMiddleware (fun: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => {
return Promise.resolve(
retryTransactionWrapper(fun, req, res, next)
).catch(err => next(err))
}
}
// ---------------------------------------------------------------------------
export {
asyncMiddleware,
asyncRetryTransactionMiddleware
}

View File

@@ -0,0 +1,114 @@
import { HttpStatusCode, HttpStatusCodeType, ServerErrorCodeType } from '@peertube/peertube-models'
import { getAccessToken } from '@server/lib/auth/oauth-model.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import express from 'express'
import { Socket } from 'socket.io'
import { logger } from '../helpers/logger.js'
import { handleOAuthAuthenticate } from '../lib/auth/oauth.js'
import { UpdateTokenSessionScheduler } from '@server/lib/schedulers/update-token-session-scheduler.js'
export 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
UpdateTokenSessionScheduler.Instance.addToUpdate(token.id, {
lastActivityDate: new Date(),
lastActivityIP: req.ip,
lastActivityDevice: req.header('user-agent')
})
return next()
})
.catch(err => {
logger.info('Cannot authenticate.', { err })
return res.fail({
status: err.status,
message: 'Token is invalid',
type: err.name
})
})
}
export function authenticateSocket (socket: Socket, next: (err?: any) => void) {
const accessToken = socket.handshake.query['accessToken']
logger.debug('Checking access token in runner.')
if (!accessToken) return next(new Error('No access token provided'))
if (typeof accessToken !== 'string') return next(new Error('Access token is invalid'))
getAccessToken(accessToken)
.then(tokenDB => {
const now = new Date()
if (!tokenDB || tokenDB.accessTokenExpiresAt < now || tokenDB.refreshTokenExpiresAt < now) {
return next(new Error('Invalid access token.'))
}
socket.handshake.auth.user = tokenDB.User
return next()
})
.catch(err => logger.error('Cannot get access token.', { err }))
}
export function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
if (req.header('authorization')) return authenticate(req, res, next)
res.locals.authenticated = false
return next()
}
export function authenticateOrFail (options: {
req: express.Request
res: express.Response
errorMessage?: string
errorStatus?: HttpStatusCodeType
errorType?: ServerErrorCodeType
}) {
const { req, res, errorMessage = req.t('Authentication is required'), errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
return new Promise<boolean>(resolve => {
// Already authenticated? (or tried to)
if (res.locals.oauth?.token.User) return resolve(true)
if (res.locals.authenticated === false || !req.header('authorization')) {
res.fail({ status: errorStatus, type: errorType, message: errorMessage })
return resolve(false)
}
authenticate(req, res, () => {
if (res.locals.oauth?.token.User) return resolve(true)
res.fail({ status: errorStatus, type: errorType, message: errorMessage })
resolve(false)
})
})
}
// ---------------------------------------------------------------------------
export function authenticateRunnerSocket (socket: Socket, next: (err?: any) => void) {
const runnerToken = socket.handshake.auth['runnerToken']
logger.debug('Checking runner token in socket.')
if (!runnerToken) return next(new Error('No runner token provided'))
if (typeof runnerToken !== 'string') return next(new Error('Runner token is invalid'))
RunnerModel.loadByToken(runnerToken)
.then(runner => {
if (!runner) return next(new Error('Invalid runner token.'))
socket.handshake.auth.runner = runner
return next()
})
.catch(err => logger.error('Cannot get runner token.', { err }))
}

58
server/core/middlewares/cache/cache.ts vendored Normal file
View File

@@ -0,0 +1,58 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { ApiCache, APICacheOptions } from './shared/index.js'
const defaultOptions: APICacheOptions = {
excludeStatus: [
HttpStatusCode.FORBIDDEN_403,
HttpStatusCode.NOT_FOUND_404
]
}
export function cacheRoute (duration: string) {
const instance = new ApiCache(defaultOptions)
return instance.buildMiddleware(duration)
}
export function cacheRouteFactory (options: APICacheOptions = {}) {
const instance = new ApiCache({ ...defaultOptions, ...options })
return { instance, middleware: instance.buildMiddleware.bind(instance) }
}
// ---------------------------------------------------------------------------
export function buildPodcastGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
}
export function buildAPVideoChaptersGroupsCache (options: {
videoId: number | string
}) {
return 'ap-video-chapters-' + options.videoId
}
// ---------------------------------------------------------------------------
export const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
}
return next()
}
]
export const apVideoChaptersSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.params.id) {
res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
}
return next()
}
]

View File

@@ -0,0 +1 @@
export * from './cache.js'

View File

@@ -0,0 +1,313 @@
// Thanks: https://github.com/kwhitley/apicache
// We duplicated the library because it is unmaintened and prevent us to upgrade to recent NodeJS versions
import express from 'express'
import { OutgoingHttpHeaders } from 'http'
import { HttpStatusCodeType } from '@peertube/peertube-models'
import { isTestInstance } from '@peertube/peertube-node-utils'
import { parseDurationToMs } from '@server/helpers/core-utils.js'
import { logger } from '@server/helpers/logger.js'
import { Redis } from '@server/lib/redis.js'
import { asyncMiddleware } from '@server/middlewares/index.js'
export interface APICacheOptions {
headerBlacklist?: string[]
excludeStatus?: HttpStatusCodeType[]
}
interface CacheObject {
status: number
headers: OutgoingHttpHeaders
data: any
encoding: BufferEncoding
timestamp: number
}
export class ApiCache {
private readonly options: APICacheOptions
private readonly timers: { [id: string]: NodeJS.Timeout } = {}
private readonly index = {
groups: [] as string[],
all: [] as string[]
}
// Cache keys per group
private groups: { [groupIndex: string]: string[] } = {}
private readonly seed: number
constructor (options: APICacheOptions) {
this.seed = new Date().getTime()
this.options = {
headerBlacklist: [],
excludeStatus: [],
...options
}
}
buildMiddleware (strDuration: string) {
const duration = parseDurationToMs(strDuration)
return asyncMiddleware(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const key = this.getCacheKey(req)
const redis = Redis.Instance.getClient()
if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
try {
const obj = await redis.hgetall(key)
if (obj?.response) {
return this.sendCachedResponse(req, res, JSON.parse(obj.response), duration)
}
return this.makeResponseCacheable(res, next, key, duration)
} catch (err) {
return this.makeResponseCacheable(res, next, key, duration)
}
}
)
}
clearGroupSafe (group: string) {
const run = async () => {
const cacheKeys = this.groups[group]
if (!cacheKeys) return
for (const key of cacheKeys) {
try {
await this.clear(key)
} catch (err) {
logger.error('Cannot clear ' + key, { err })
}
}
delete this.groups[group]
}
void run()
}
private getCacheKey (req: express.Request) {
return Redis.Instance.getPrefix() + 'api-cache-' + this.seed + '-' + req.originalUrl
}
private shouldCacheResponse (response: express.Response) {
if (!response) return false
if (this.options.excludeStatus.includes(response.statusCode as HttpStatusCodeType)) return false
return true
}
private addIndexEntries (key: string, res: express.Response) {
this.index.all.unshift(key)
const groups = res.locals.apicacheGroups || []
for (const group of groups) {
if (!this.groups[group]) this.groups[group] = []
this.groups[group].push(key)
}
}
private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
return Object.keys(headers)
.filter(key => !this.options.headerBlacklist.includes(key))
.reduce((acc, header) => {
acc[header] = headers[header]
return acc
}, {})
}
private createCacheObject (status: number, headers: OutgoingHttpHeaders, data: any, encoding: BufferEncoding) {
return {
status,
headers: this.filterBlacklistedHeaders(headers),
data,
encoding,
// Seconds since epoch, used to properly decrement max-age headers in cached responses.
timestamp: new Date().getTime() / 1000
} as CacheObject
}
private async cacheResponse (key: string, value: object, duration: number) {
const redis = Redis.Instance.getClient()
if (Redis.Instance.isConnected()) {
await Promise.all([
redis.hset(key, 'response', JSON.stringify(value)),
redis.hset(key, 'duration', duration + ''),
redis.expire(key, duration / 1000)
])
}
// add automatic cache clearing from duration, includes max limit on setTimeout
this.timers[key] = setTimeout(() => {
this.clear(key)
.catch(err => logger.error('Cannot clear Redis key %s.', key, { err }))
}, Math.min(duration, 2147483647))
}
private accumulateContent (res: express.Response, content: any) {
if (!content) return
if (typeof content === 'string') {
res.locals.apicache.content = (res.locals.apicache.content || '') + content
return
}
if (Buffer.isBuffer(content)) {
let oldContent = res.locals.apicache.content
if (typeof oldContent === 'string') {
oldContent = Buffer.from(oldContent)
}
if (!oldContent) {
oldContent = Buffer.alloc(0)
}
res.locals.apicache.content = Buffer.concat(
[ oldContent, content ],
oldContent.length + content.length
)
return
}
res.locals.apicache.content = content
}
private makeResponseCacheable (res: express.Response, next: express.NextFunction, key: string, duration: number) {
const self = this
res.locals.apicache = {
write: res.write.bind(res),
writeHead: res.writeHead.bind(res),
end: res.end.bind(res),
cacheable: true,
content: undefined,
headers: undefined
}
// Patch express
res.writeHead = function () {
if (self.shouldCacheResponse(res)) {
res.setHeader('cache-control', 'max-age=' + (duration / 1000).toFixed(0))
} else {
res.setHeader('cache-control', 'no-cache, no-store, must-revalidate')
}
res.locals.apicache.headers = Object.assign({}, res.getHeaders())
return res.locals.apicache.writeHead.apply(this, arguments as any)
}
res.write = function (chunk: any) {
self.accumulateContent(res, chunk)
return res.locals.apicache.write.apply(this, arguments as any)
}
res.end = function (content: any, encoding: BufferEncoding) {
if (self.shouldCacheResponse(res)) {
self.accumulateContent(res, content)
if (res.locals.apicache.cacheable && res.locals.apicache.content) {
self.addIndexEntries(key, res)
const headers = res.locals.apicache.headers || res.getHeaders()
const cacheObject = self.createCacheObject(
res.statusCode,
headers,
res.locals.apicache.content,
encoding
)
self.cacheResponse(key, cacheObject, duration)
.catch(err => logger.error('Cannot cache response', { err }))
}
}
res.locals.apicache.end.apply(this, arguments as any)
} as any
next()
}
private sendCachedResponse (request: express.Request, response: express.Response, cacheObject: CacheObject, duration: number) {
const headers = response.getHeaders()
if (isTestInstance()) {
Object.assign(headers, {
'x-api-cache-cached': 'true'
})
}
Object.assign(headers, this.filterBlacklistedHeaders(cacheObject.headers || {}), {
// Set properly decremented max-age header
// This ensures that max-age is in sync with the cache expiration
'cache-control': 'max-age=' +
Math.max(
0,
duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp)
).toFixed(0)
})
// unstringify buffers
let data = cacheObject.data
if (data?.type === 'Buffer') {
data = typeof data.data === 'number'
? Buffer.alloc(data.data)
: Buffer.from(data.data)
}
// Test Etag against If-None-Match for 304
const cachedEtag = cacheObject.headers.etag
const requestEtag = request.headers['if-none-match']
if (requestEtag && cachedEtag === requestEtag) {
response.writeHead(304, headers)
return response.end()
}
response.writeHead(cacheObject.status || 200, headers)
return response.end(data, cacheObject.encoding)
}
private async clear (target: string) {
const redis = Redis.Instance.getClient()
if (target) {
clearTimeout(this.timers[target])
delete this.timers[target]
try {
await redis.del(target)
} catch (err) {
logger.error('Cannot delete %s in redis cache.', target, { err })
}
this.index.all = this.index.all.filter(key => key !== target)
} else {
for (const key of this.index.all) {
clearTimeout(this.timers[key])
delete this.timers[key]
try {
await redis.del(key)
} catch (err) {
logger.error('Cannot delete %s in redis cache.', key, { err })
}
}
this.index.all = []
}
return this.index
}
}

View File

@@ -0,0 +1 @@
export * from './api-cache.js'

View File

@@ -0,0 +1,47 @@
import { contentSecurityPolicy } from 'helmet'
import { CONFIG } from '../initializers/config.js'
const baseDirectives = Object.assign({},
{
defaultSrc: [ '\'none\'' ], // by default, not specifying default-src = '*'
connectSrc: [ '*', 'data:' ],
mediaSrc: [ '\'self\'', 'https:', 'blob:' ],
fontSrc: [ '\'self\'', 'data:' ],
imgSrc: [ '\'self\'', 'data:', 'blob:' ],
scriptSrc: [ '\'self\' \'unsafe-inline\' \'unsafe-eval\'', 'blob:' ],
scriptSrcAttr: [ '\'unsafe-inline\'' ],
styleSrc: [ '\'self\' \'unsafe-inline\'' ],
objectSrc: [ '\'none\'' ], // only define to allow plugins, else let defaultSrc 'none' block it
formAction: [ '\'self\'' ],
frameAncestors: [ '\'none\'' ],
baseUri: [ '\'self\'' ],
manifestSrc: [ '\'self\'' ],
frameSrc: [ '\'self\'' ], // instead of deprecated child-src / self because of test-embed
workerSrc: [ '\'self\'', 'blob:' ] // instead of deprecated child-src
},
CONFIG.CSP.REPORT_URI
? { reportUri: CONFIG.CSP.REPORT_URI }
: {},
CONFIG.WEBSERVER.SCHEME === 'https'
? { upgradeInsecureRequests: [] }
: {}
)
const baseCSP = contentSecurityPolicy({
directives: baseDirectives,
reportOnly: CONFIG.CSP.REPORT_ONLY
})
const embedCSP = contentSecurityPolicy({
directives: Object.assign({}, baseDirectives, { frameAncestors: [ '*' ] }),
reportOnly: CONFIG.CSP.REPORT_ONLY
})
// ---------------------------------------------------------------------------
export {
baseCSP,
embedCSP
}

View File

@@ -0,0 +1,15 @@
import * as express from 'express'
const advertiseDoNotTrack = (_, res: express.Response, next: express.NextFunction) => {
if (!res.headersSent) {
res.setHeader('Tk', 'N')
}
return next()
}
// ---------------------------------------------------------------------------
export {
advertiseDoNotTrack
}

View File

@@ -0,0 +1,16 @@
import express from 'express'
function openapiOperationDoc (options: {
url?: string
operationId?: string
}) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
res.locals.docUrl = options.url || 'https://docs.joinpeertube.org/api-rest-reference.html#operation/' + options.operationId
if (next) return next()
}
}
export {
openapiOperationDoc
}

View File

@@ -0,0 +1,60 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import express from 'express'
import { ProblemDocument, ProblemDocumentExtension } from 'http-problem-details'
function apiFailMiddleware (req: express.Request, res: express.Response, next: express.NextFunction) {
res.fail = options => {
const { status = HttpStatusCode.BAD_REQUEST_400, message, title, type, data, instance, tags, logLevel = 'debug' } = options
const extension = new ProblemDocumentExtension({
...data,
docs: res.locals.docUrl,
code: type
})
const json = new ProblemDocument({
status,
title,
instance,
detail: message,
type: type
? `https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/${type}`
: undefined
}, extension)
logger.log(logLevel, 'Bad HTTP request.', { json, tags })
res.status(status)
// Cannot display a proper error to the client since headers are already sent
if (res.headersSent) return
res.setHeader('Content-Type', 'application/problem+json')
res.json(json)
}
if (next) next()
}
function handleStaticError (err: any, req: express.Request, res: express.Response, next: express.NextFunction) {
const message = err.message || ''
if (message.includes('ENOENT')) {
return res.fail({
status: err.status || HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: err.message,
type: err.name
})
}
return next(err)
}
export {
apiFailMiddleware,
handleStaticError
}

View File

@@ -0,0 +1,14 @@
export * from './validators/index.js'
export * from './cache/index.js'
export * from './activitypub.js'
export * from './async.js'
export * from './auth.js'
export * from './pagination.js'
export * from './rate-limiter.js'
export * from './servers.js'
export * from './sort.js'
export * from './user-right.js'
export * from './dnt.js'
export * from './error.js'
export * from './doc.js'
export * from './csp.js'

View File

@@ -0,0 +1,19 @@
import express from 'express'
import { forceNumber } from '@peertube/peertube-core-utils'
import { PAGINATION } from '../initializers/constants.js'
function setDefaultPagination (req: express.Request, res: express.Response, next: express.NextFunction) {
if (!req.query.start) req.query.start = 0
else req.query.start = forceNumber(req.query.start)
if (!req.query.count) req.query.count = PAGINATION.GLOBAL.COUNT.DEFAULT
else req.query.count = forceNumber(req.query.count)
return next()
}
// ---------------------------------------------------------------------------
export {
setDefaultPagination
}

View File

@@ -0,0 +1,62 @@
import express from 'express'
import RateLimit, { Options as RateLimitHandlerOptions } from 'express-rate-limit'
import { UserRole, UserRoleType } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import { optionalAuthenticate } from './auth.js'
import { logger } from '@server/helpers/logger.js'
const whitelistRoles = new Set<UserRoleType>([ UserRole.ADMINISTRATOR, UserRole.MODERATOR ])
export function buildRateLimiter (options: {
windowMs: number
max: number
skipFailedRequests?: boolean
}) {
return RateLimit({
windowMs: options.windowMs,
max: options.max,
skipFailedRequests: options.skipFailedRequests,
handler: (req, res, next, options) => {
// Bypass rate limit for registered runners
if (req.body?.runnerToken) {
return RunnerModel.loadByToken(req.body.runnerToken)
.then(runner => {
if (runner) return next()
return sendRateLimited(req, res, options)
})
}
// Bypass rate limit for admins/moderators
return optionalAuthenticate(req, res, () => {
if (res.locals.authenticated === true && whitelistRoles.has(res.locals.oauth.token.User.role)) {
return next()
}
return sendRateLimited(req, res, options)
})
}
})
}
export const apiRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.API.WINDOW_MS,
max: CONFIG.RATES_LIMIT.API.MAX
})
export const activityPubRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.ACTIVITY_PUB.WINDOW_MS,
max: CONFIG.RATES_LIMIT.ACTIVITY_PUB.MAX
})
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function sendRateLimited (req: express.Request, res: express.Response, options: RateLimitHandlerOptions) {
logger.debug('Rate limit exceeded for route ' + req.originalUrl)
return res.status(options.statusCode).send(options.message)
}

View File

@@ -0,0 +1,29 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getHostWithPort } from '../helpers/express-utils.js'
function setBodyHostsPort (req: express.Request, res: express.Response, next: express.NextFunction) {
if (!req.body.hosts) return next()
for (let i = 0; i < req.body.hosts.length; i++) {
const hostWithPort = getHostWithPort(req.body.hosts[i])
// Problem with the url parsing?
if (hostWithPort === null) {
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Could not parse hosts'
})
}
req.body.hosts[i] = hostWithPort
}
return next()
}
// ---------------------------------------------------------------------------
export {
setBodyHostsPort
}

View File

@@ -0,0 +1,23 @@
import express from 'express'
export const setDefaultSort = setDefaultSortFactory('-createdAt')
export const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
export const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
export const setDefaultSearchSort = setDefaultSortFactory('-match')
export const setBlacklistSort = setDefaultSortFactory('-createdAt')
export const setLiveSessionsSort = setDefaultSortFactory('startDate')
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function setDefaultSortFactory (sort: string) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!req.query.sort) req.query.sort = sort
return next()
}
}

View File

@@ -0,0 +1,20 @@
import express from 'express'
import { HttpStatusCode, UserRightType } from '@peertube/peertube-models'
import { logger } from '../helpers/logger.js'
export function ensureUserHasRight (userRight: UserRightType) {
return function (req: express.Request, res: express.Response, next: express.NextFunction) {
const user = res.locals.oauth.token.user
if (user.hasRight(userRight) === false) {
const message = `User ${user.username} does not have right (No. ${userRight}) to access to ${req.path}.`
logger.info(message)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message
})
}
return next()
}
}

View File

@@ -0,0 +1,254 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { forceNumber } from '@peertube/peertube-core-utils'
import { AbuseCreate, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import {
areAbusePredefinedReasonsValid,
isAbuseFilterValid,
isAbuseMessageValid,
isAbuseModerationCommentValid,
isAbusePredefinedReasonValid,
isAbuseReasonValid,
isAbuseStateValid,
isAbuseTimestampCoherent,
isAbuseTimestampValid,
isAbuseVideoIsValid
} from '@server/helpers/custom-validators/abuses.js'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID, toIntOrNull } from '@server/helpers/custom-validators/misc.js'
import { logger } from '@server/helpers/logger.js'
import { AbuseMessageModel } from '@server/models/abuse/abuse-message.js'
import { areValidationErrors, doesAbuseExist, doesAccountIdExist, doesCommentIdExist, doesVideoExist } from './shared/index.js'
const abuseReportValidator = [
body('account.id')
.optional()
.custom(isIdValid),
body('video.id')
.optional()
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid),
body('video.startAt')
.optional()
.customSanitizer(toIntOrNull)
.custom(isAbuseTimestampValid),
body('video.endAt')
.optional()
.customSanitizer(toIntOrNull)
.custom(isAbuseTimestampValid)
.bail()
.custom(isAbuseTimestampCoherent)
.withMessage('Should have a startAt timestamp beginning before endAt'),
body('comment.id')
.optional()
.custom(isIdValid),
body('reason')
.custom(isAbuseReasonValid),
body('predefinedReasons')
.optional()
.custom(areAbusePredefinedReasonsValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: AbuseCreate = req.body
if (body.video?.id && !await doesVideoExist(body.video.id, res)) return
if (body.account?.id && !await doesAccountIdExist({ id: body.account.id, req, res, checkIsLocal: false, checkCanManage: false })) return
if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return
if (!body.video?.id && !body.account?.id && !body.comment?.id) {
res.fail({ message: 'video id or account id or comment id is required.' })
return
}
return next()
}
]
const abuseGetValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAbuseExist(req.params.id, res)) return
return next()
}
]
const abuseUpdateValidator = [
param('id')
.custom(isIdValid),
body('state')
.optional()
.custom(isAbuseStateValid),
body('moderationComment')
.optional()
.custom(isAbuseModerationCommentValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAbuseExist(req.params.id, res)) return
return next()
}
]
const abuseListForAdminsValidator = [
query('id')
.optional()
.custom(isIdValid),
query('filter')
.optional()
.custom(isAbuseFilterValid),
query('predefinedReason')
.optional()
.custom(isAbusePredefinedReasonValid),
query('search')
.optional()
.custom(exists),
query('state')
.optional()
.custom(isAbuseStateValid),
query('videoIs')
.optional()
.custom(isAbuseVideoIsValid),
query('searchReporter')
.optional()
.custom(exists),
query('searchReportee')
.optional()
.custom(exists),
query('searchVideo')
.optional()
.custom(exists),
query('searchVideoChannel')
.optional()
.custom(exists),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const abuseListForUserValidator = [
query('id')
.optional()
.custom(isIdValid),
query('search')
.optional()
.custom(exists),
query('state')
.optional()
.custom(isAbuseStateValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const getAbuseValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAbuseExist(req.params.id, res)) return
const user = res.locals.oauth.token.user
const abuse = res.locals.abuse
if (user.hasRight(UserRight.MANAGE_ABUSES) !== true && abuse.reporterAccountId !== user.Account.id) {
const message = `User ${user.username} does not have right to get abuse ${abuse.id}`
logger.warn(message)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message
})
}
return next()
}
]
const checkAbuseValidForMessagesValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const abuse = res.locals.abuse
if (abuse.ReporterAccount.isLocal() === false) {
return res.fail({ message: 'This abuse was created by a user of your instance.' })
}
return next()
}
]
const addAbuseMessageValidator = [
body('message')
.custom(isAbuseMessageValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const deleteAbuseMessageValidator = [
param('messageId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const user = res.locals.oauth.token.user
const abuse = res.locals.abuse
const messageId = forceNumber(req.params.messageId)
const abuseMessage = await AbuseMessageModel.loadByIdAndAbuseId(messageId, abuse.id)
if (!abuseMessage) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Abuse message not found'
})
}
if (user.hasRight(UserRight.MANAGE_ABUSES) !== true && abuseMessage.accountId !== user.Account.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot delete this abuse message'
})
}
res.locals.abuseMessage = abuseMessage
return next()
}
]
// ---------------------------------------------------------------------------
export {
abuseListForAdminsValidator,
abuseReportValidator,
abuseGetValidator,
addAbuseMessageValidator,
checkAbuseValidForMessagesValidator,
abuseUpdateValidator,
deleteAbuseMessageValidator,
abuseListForUserValidator,
getAbuseValidator
}

View File

@@ -0,0 +1,31 @@
import { UserRight } from '@peertube/peertube-models'
import express from 'express'
import { param } from 'express-validator'
import { areValidationErrors, checkCanManageAccount, doesAccountHandleExist } from './shared/index.js'
export const accountHandleGetValidatorFactory = (options: {
checkCanManage: boolean
checkIsLocal: boolean
}) => {
const { checkCanManage, checkIsLocal } = options
return [
param('handle')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.params.handle, req, res, checkIsLocal, checkCanManage })) return
if (checkCanManage) {
const user = res.locals.oauth.token.User
if (!checkCanManageAccount({ account: res.locals.account, user, req, res, specialRight: UserRight.MANAGE_USERS })) {
return false
}
}
return next()
}
]
}

View File

@@ -0,0 +1,29 @@
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { isRootActivityValid } from '../../../helpers/custom-validators/activitypub/activity.js'
import { logger } from '../../../helpers/logger.js'
async function activityPubValidator (req: express.Request, res: express.Response, next: express.NextFunction) {
logger.debug('Checking activity pub parameters')
if (!isRootActivityValid(req.body)) {
logger.warn('Incorrect activity parameters.', { activity: req.body })
return res.fail({ message: 'Incorrect activity' })
}
const serverActor = await getServerActor()
const remoteActor = res.locals.signature.actor
if (serverActor.id === remoteActor.id || remoteActor.serverId === null) {
logger.error('Receiving request in INBOX by ourselves!', req.body)
return res.status(HttpStatusCode.CONFLICT_409).end()
}
return next()
}
// ---------------------------------------------------------------------------
export {
activityPubValidator
}

View File

@@ -0,0 +1,3 @@
export * from './activity.js'
export * from './signature.js'
export * from './pagination.js'

View File

@@ -0,0 +1,25 @@
import express from 'express'
import { query } from 'express-validator'
import { PAGINATION } from '@server/initializers/constants.js'
import { areValidationErrors } from '../shared/index.js'
const apPaginationValidator = [
query('page')
.optional()
.isInt({ min: 1 }),
query('size')
.optional()
.isInt({ min: 0, max: PAGINATION.OUTBOX.COUNT.MAX }).withMessage(`Should have a valid page size (max: ${PAGINATION.OUTBOX.COUNT.MAX})`),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
apPaginationValidator
}

View File

@@ -0,0 +1,39 @@
import express from 'express'
import { body } from 'express-validator'
import {
isSignatureCreatorValid,
isSignatureTypeValid,
isSignatureValueValid
} from '../../../helpers/custom-validators/activitypub/signature.js'
import { isDateValid } from '../../../helpers/custom-validators/misc.js'
import { logger } from '../../../helpers/logger.js'
import { areValidationErrors } from '../shared/index.js'
const signatureValidator = [
body('signature.type')
.optional()
.custom(isSignatureTypeValid),
body('signature.created')
.optional()
.custom(isDateValid).withMessage('Should have a signature created date that conforms to ISO 8601'),
body('signature.creator')
.optional()
.custom(isSignatureCreatorValid),
body('signature.signatureValue')
.optional()
.custom(isSignatureValueValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking Linked Data Signature parameter', { parameters: { signature: req.body.signature } })
if (areValidationErrors(req, res, { omitLog: true })) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
signatureValidator
}

View File

@@ -0,0 +1,4 @@
import { updateActorImageValidatorFactory } from './shared/images.js'
export const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile')
export const updateBannerValidator = updateActorImageValidatorFactory('bannerfile')

View File

@@ -0,0 +1,43 @@
import { CommentAutomaticTagPoliciesUpdate } from '@peertube/peertube-models'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import express from 'express'
import { body, param } from 'express-validator'
import { doesAccountHandleExist } from './shared/accounts.js'
import { areValidationErrors } from './shared/utils.js'
export const manageAccountAutomaticTagsValidator = [
param('accountName')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkCanManage: true })) return
return next()
}
]
export const updateAutomaticTagPoliciesValidator = [
...manageAccountAutomaticTagsValidator,
body('review')
.custom(isStringArray).withMessage('Should have a valid review array'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body = req.body as CommentAutomaticTagPoliciesUpdate
const tagsObj = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account)
const available = new Set(tagsObj.available.map(({ name }) => name))
for (const name of body.review) {
if (!available.has(name)) {
return res.fail({ message: `${name} is not an available automatic tag` })
}
}
return next()
}
]

View File

@@ -0,0 +1,179 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { areValidActorHandles } from '@server/helpers/custom-validators/activitypub/actor.js'
import { getServerActor } from '@server/models/application/application.js'
import { arrayify } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers.js'
import { WEBSERVER } from '../../initializers/constants.js'
import { AccountBlocklistModel } from '../../models/account/account-blocklist.js'
import { ServerBlocklistModel } from '../../models/server/server-blocklist.js'
import { ServerModel } from '../../models/server/server.js'
import { areValidationErrors, doesAccountHandleExist } from './shared/index.js'
const blockAccountValidator = [
body('accountName')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return
const user = res.locals.oauth.token.User
const accountToBlock = res.locals.account
if (user.Account.id === accountToBlock.id) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'You cannot block yourself.'
})
return
}
return next()
}
]
const unblockAccountByAccountValidator = [
param('accountName')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return
const user = res.locals.oauth.token.User
const targetAccount = res.locals.account
if (!await doesUnblockAccountExist(user.Account.id, targetAccount.id, res)) return
return next()
}
]
const unblockAccountByServerValidator = [
param('accountName')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return
const serverActor = await getServerActor()
const targetAccount = res.locals.account
if (!await doesUnblockAccountExist(serverActor.Account.id, targetAccount.id, res)) return
return next()
}
]
const blockServerValidator = [
body('host')
.custom(isHostValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const host: string = req.body.host
if (host === WEBSERVER.HOST) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'You cannot block your own server.'
})
}
const server = await ServerModel.loadOrCreateByHost(host)
res.locals.server = server
return next()
}
]
const unblockServerByAccountValidator = [
param('host')
.custom(isHostValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const user = res.locals.oauth.token.User
if (!await doesUnblockServerExist(user.Account.id, req.params.host, res)) return
return next()
}
]
const unblockServerByServerValidator = [
param('host')
.custom(isHostValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const serverActor = await getServerActor()
if (!await doesUnblockServerExist(serverActor.Account.id, req.params.host, res)) return
return next()
}
]
const blocklistStatusValidator = [
query('hosts')
.optional()
.customSanitizer(arrayify)
.custom(isEachUniqueHostValid).withMessage('Should have a valid hosts array'),
query('accounts')
.optional()
.customSanitizer(arrayify)
.custom(areValidActorHandles).withMessage('Should have a valid accounts array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
blockServerValidator,
blockAccountValidator,
unblockAccountByAccountValidator,
unblockServerByAccountValidator,
unblockAccountByServerValidator,
unblockServerByServerValidator,
blocklistStatusValidator
}
// ---------------------------------------------------------------------------
async function doesUnblockAccountExist (accountId: number, targetAccountId: number, res: express.Response) {
const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
if (!accountBlock) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Account block entry not found.'
})
return false
}
res.locals.accountBlock = accountBlock
return true
}
async function doesUnblockServerExist (accountId: number, host: string, res: express.Response) {
const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
if (!serverBlock) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Server block entry not found.'
})
return false
}
res.locals.serverBlock = serverBlock
return true
}

View File

@@ -0,0 +1,29 @@
import { BulkRemoveCommentsOfBody, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk.js'
import express from 'express'
import { body } from 'express-validator'
import { areValidationErrors, doesAccountHandleExist } from './shared/index.js'
export const bulkRemoveCommentsOfValidator = [
body('accountName')
.exists(),
body('scope')
.custom(isBulkRemoveCommentsOfScopeValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return
const user = res.locals.oauth.token.User
const body = req.body as BulkRemoveCommentsOfBody
if (body.scope === 'instance' && user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User cannot remove any comments of this instance.'
})
}
return next()
}
]

View File

@@ -0,0 +1,295 @@
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { isConfigLogoTypeValid, isLogoImageFile } from '@server/helpers/custom-validators/config.js'
import { isIntOrNull } from '@server/helpers/custom-validators/misc.js'
import { isPlayerThemeValid } from '@server/helpers/custom-validators/player-settings.js'
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
import { isVideoCommentsPolicyValid, isVideoLicenceValid, isVideoPrivacyValid } from '@server/helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
import { guessLanguageFromReq } from '@server/helpers/i18n.js'
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import express from 'express'
import { body, param } from 'express-validator'
import { getBrowseVideosDefaultScopeError, getBrowseVideosDefaultSortError } from '../../helpers/custom-validators/browse-videos.js'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
import { areValidationErrors } from './shared/index.js'
export const customConfigUpdateValidator = [
body('instance.name').exists(),
body('instance.shortDescription').exists(),
body('instance.description').exists(),
body('instance.terms').exists(),
body('instance.codeOfConduct').exists(),
body('instance.creationReason').exists(),
body('instance.moderationInformation').exists(),
body('instance.administrator').exists(),
body('instance.maintenanceLifetime').exists(),
body('instance.businessModel').exists(),
body('instance.hardwareInformation').exists(),
body('instance.serverCountry').exists(),
body('instance.support.text').exists(),
body('instance.social.externalLink').exists(),
body('instance.social.mastodonLink').exists(),
body('instance.social.blueskyLink').exists(),
body('instance.defaultLanguage').exists(),
body('instance.social.xLink').exists(),
body('instance.isNSFW').isBoolean(),
body('instance.languages').custom(isStringArray),
body('instance.categories').custom(isNumberArray),
body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid),
body('instance.defaultClientRoute').exists(),
body('instance.customizations.css').exists(),
body('instance.customizations.javascript').exists(),
body('services.twitter.username').exists(),
body('cache.previews.size').isInt(),
body('cache.captions.size').isInt(),
body('cache.torrents.size').isInt(),
body('cache.storyboards.size').isInt(),
body('signup.enabled').isBoolean(),
body('signup.limit').isInt(),
body('signup.requiresEmailVerification').isBoolean(),
body('signup.requiresApproval').isBoolean(),
body('signup.minimumAge').isInt(),
body('admin.email').isEmail(),
body('contactForm.enabled').isBoolean(),
body('user.history.videos.enabled').isBoolean(),
body('user.videoQuota').custom(isUserVideoQuotaValid),
body('user.videoQuotaDaily').custom(isUserVideoQuotaDailyValid),
body('videoChannels.maxPerUser').isInt(),
body('transcoding.enabled').isBoolean(),
body('transcoding.originalFile.keep').isBoolean(),
body('transcoding.allowAdditionalExtensions').isBoolean(),
body('transcoding.threads').isInt(),
body('transcoding.concurrency').isInt({ min: 1 }),
body('transcoding.resolutions.0p').isBoolean(),
body('transcoding.resolutions.144p').isBoolean(),
body('transcoding.resolutions.240p').isBoolean(),
body('transcoding.resolutions.360p').isBoolean(),
body('transcoding.resolutions.480p').isBoolean(),
body('transcoding.resolutions.720p').isBoolean(),
body('transcoding.resolutions.1080p').isBoolean(),
body('transcoding.resolutions.1440p').isBoolean(),
body('transcoding.resolutions.2160p').isBoolean(),
body('transcoding.remoteRunners.enabled').isBoolean(),
body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(),
body('transcoding.fps.max').custom(isIntOrNull),
body('transcoding.webVideos.enabled').isBoolean(),
body('transcoding.hls.enabled').isBoolean(),
body('videoStudio.enabled').isBoolean(),
body('videoStudio.remoteRunners.enabled').isBoolean(),
body('videoFile.update.enabled').isBoolean(),
body('import.videos.concurrency').isInt({ min: 0 }),
body('import.videos.http.enabled').isBoolean(),
body('import.videos.torrent.enabled').isBoolean(),
body('import.videoChannelSynchronization.enabled').isBoolean(),
body('import.users.enabled').isBoolean(),
body('export.users.enabled').isBoolean(),
body('export.users.maxUserVideoQuota').exists(),
body('export.users.exportExpiration').exists(),
body('trending.videos.algorithms.default').exists(),
body('trending.videos.algorithms.enabled').exists(),
body('followers.instance.enabled').isBoolean(),
body('followers.instance.manualApproval').isBoolean(),
body('followers.channels.enabled').isBoolean(),
body('theme.default').custom(v => isThemeNameValid(v) && isThemeRegistered(v)),
body('broadcastMessage.enabled').isBoolean(),
body('broadcastMessage.message').exists(),
body('broadcastMessage.level').exists(),
body('broadcastMessage.dismissable').isBoolean(),
body('live.enabled').isBoolean(),
body('live.allowReplay').isBoolean(),
body('live.maxDuration').isInt(),
body('live.maxInstanceLives').custom(isIntOrNull),
body('live.maxUserLives').custom(isIntOrNull),
body('live.transcoding.enabled').isBoolean(),
body('live.transcoding.threads').isInt(),
body('live.transcoding.resolutions.144p').isBoolean(),
body('live.transcoding.resolutions.240p').isBoolean(),
body('live.transcoding.resolutions.360p').isBoolean(),
body('live.transcoding.resolutions.480p').isBoolean(),
body('live.transcoding.resolutions.720p').isBoolean(),
body('live.transcoding.resolutions.1080p').isBoolean(),
body('live.transcoding.resolutions.1440p').isBoolean(),
body('live.transcoding.resolutions.2160p').isBoolean(),
body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(),
body('live.transcoding.fps.max').custom(isIntOrNull),
body('live.transcoding.remoteRunners.enabled').isBoolean(),
body('search.remoteUri.users').isBoolean(),
body('search.remoteUri.anonymous').isBoolean(),
body('search.searchIndex.enabled').isBoolean(),
body('search.searchIndex.url').exists(),
body('search.searchIndex.disableLocalSearch').isBoolean(),
body('search.searchIndex.isDefaultSearch').isBoolean(),
body('defaults.publish.commentsPolicy').custom(isVideoCommentsPolicyValid),
body('defaults.publish.privacy').custom(isVideoPrivacyValid),
body('defaults.publish.licence').optional().custom(isVideoLicenceValid),
body('defaults.p2p.webapp.enabled').isBoolean(),
body('defaults.p2p.embed.enabled').isBoolean(),
body('defaults.player.autoPlay').isBoolean(),
body('defaults.player.theme').custom(isPlayerThemeValid),
body('email.body.signature').exists(),
body('email.subject.prefix').exists(),
body('videoComments.acceptRemoteComments').isBoolean(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!checkInvalidConfigIfEmailDisabled(req.body, req, res)) return
if (!checkInvalidTranscodingConfig(req.body, req, res)) return
if (!checkInvalidSynchronizationConfig(req.body, req, res)) return
if (!checkInvalidLiveConfig(req.body, req, res)) return
if (!checkInvalidVideoStudioConfig(req.body, req, res)) return
if (!checkInvalidSearchConfig(req.body, req, res)) return
if (!checkInvalidBrowseVideosConfig(req.body, req, res)) return
return next()
}
]
export function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) {
if (!CONFIG.WEBADMIN.CONFIGURATION.EDITION.ALLOWED) {
return res.fail({
status: HttpStatusCode.METHOD_NOT_ALLOWED_405,
message: req.t('Server configuration is static and cannot be edited')
})
}
return next()
}
export const updateOrDeleteLogoValidator = [
param('logoType')
.custom(isConfigLogoTypeValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const updateInstanceLogoValidator = [
body('logofile').custom((value, { req }) => isLogoImageFile(req.files, 'logofile')).withMessage(
'This file is not supported or too large. Please, make sure it is of the following type : ' +
CONSTRAINTS_FIELDS.LOGO.IMAGE.EXTNAME.join(', ')
),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (isEmailEnabled()) return true
if (customConfig.signup.requiresEmailVerification === true) {
res.fail({ message: req.t('SMTP is not configured but you require signup email verification.') })
return false
}
return true
}
function checkInvalidTranscodingConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (customConfig.transcoding.enabled === false) return true
if (customConfig.transcoding.webVideos.enabled === false && customConfig.transcoding.hls.enabled === false) {
res.fail({ message: req.t('You need to enable at least web_videos transcoding or hls transcoding') })
return false
}
return true
}
function checkInvalidSynchronizationConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (customConfig.import.videoChannelSynchronization.enabled && !customConfig.import.videos.http.enabled) {
res.fail({ message: req.t('You need to enable HTTP video import in order to enable channel synchronization') })
return false
}
return true
}
function checkInvalidLiveConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (customConfig.live.enabled === false) return true
if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
res.fail({ message: req.t('You cannot allow live replay if transcoding is not enabled') })
return false
}
return true
}
function checkInvalidVideoStudioConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (customConfig.videoStudio.enabled === false) return true
if (customConfig.videoStudio.enabled === true && customConfig.transcoding.enabled === false) {
res.fail({ message: req.t('You cannot enable video studio if transcoding is not enabled') })
return false
}
return true
}
function checkInvalidSearchConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
if (customConfig.search.searchIndex.enabled === false) return true
if (customConfig.search.searchIndex.enabled === true && customConfig.search.remoteUri.users === false) {
res.fail({ message: req.t('You cannot enable search index without enabling remote URI search for users.') })
return false
}
return true
}
function checkInvalidBrowseVideosConfig (customConfig: CustomConfig, req: express.Request, res: express.Response) {
const sortError = getBrowseVideosDefaultSortError(
customConfig.client.browseVideos.defaultSort,
customConfig.trending.videos.algorithms.enabled,
guessLanguageFromReq(req, res)
)
if (sortError) {
res.fail({ message: sortError })
return false
}
const scopeError = getBrowseVideosDefaultScopeError(customConfig.client.browseVideos.defaultScope, guessLanguageFromReq(req, res))
if (scopeError) {
res.fail({ message: scopeError })
return false
}
return true
}

View File

@@ -0,0 +1,282 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { EventRegistrationModel } from '@server/models/video/event-registration.js'
import { EventModel } from '@server/models/video/event.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import express from 'express'
import { body, param } from 'express-validator'
import { areValidationErrors } from './shared/utils.js'
const phoneRegexp = /^[0-9+()\-\s]{7,20}$/
const requiredRegistrationFields = [
body('firstName')
.trim()
.notEmpty()
.withMessage('First name is required'),
body('lastName')
.trim()
.notEmpty()
.withMessage('Last name is required'),
body('email')
.trim()
.isEmail()
.withMessage('Email must be valid')
.bail()
.normalizeEmail(),
body('phone')
.trim()
.matches(phoneRegexp)
.withMessage('Phone must be valid'),
body('companyName')
.trim()
.notEmpty()
.withMessage('Company name is required'),
body('jobTitle')
.trim()
.notEmpty()
.withMessage('Job title is required'),
body('country')
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('Country must be between 2 and 100 characters')
]
const optionalRegistrationFields = [
body('firstName')
.optional()
.trim()
.notEmpty()
.withMessage('First name cannot be empty'),
body('lastName')
.optional()
.trim()
.notEmpty()
.withMessage('Last name cannot be empty'),
body('email')
.optional()
.trim()
.isEmail()
.withMessage('Email must be valid')
.bail()
.normalizeEmail(),
body('phone')
.optional()
.trim()
.matches(phoneRegexp)
.withMessage('Phone must be valid'),
body('companyName')
.optional()
.trim()
.notEmpty()
.withMessage('Company name cannot be empty'),
body('jobTitle')
.optional()
.trim()
.notEmpty()
.withMessage('Job title cannot be empty'),
body('country')
.optional()
.trim()
.isLength({ min: 2, max: 100 })
.withMessage('Country must be between 2 and 100 characters')
]
async function loadEventWithChannel (eventId: number) {
return EventModel.findByPk(eventId, {
include: [
{
model: VideoChannelModel.scope([ 'WITH_ACCOUNT' ]),
required: true
}
]
})
}
export const createEventRegistrationValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
...requiredRegistrationFields,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const eventId = parseInt(req.params.eventId)
const event = await loadEventWithChannel(eventId)
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
const resLocals = res.locals as any
resLocals.event = event
return next()
}
]
export const listEventRegistrationsValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const event = await loadEventWithChannel(parseInt(req.params.eventId))
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
const resLocals = res.locals as any
resLocals.event = event
return next()
}
]
export const getEventRegistrationValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
param('registrationId')
.custom(isIdValid)
.withMessage('Invalid registration id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const eventId = parseInt(req.params.eventId)
const registrationId = parseInt(req.params.registrationId)
const [ event, registration ] = await Promise.all([
loadEventWithChannel(eventId),
EventRegistrationModel.loadByIdAndEventId(registrationId, eventId)
])
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
if (!registration) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Registration not found'
})
}
const resLocals = res.locals as any
resLocals.event = event
resLocals.eventRegistration = registration
return next()
}
]
export const updateEventRegistrationValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
param('registrationId')
.custom(isIdValid)
.withMessage('Invalid registration id'),
...optionalRegistrationFields,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const eventId = parseInt(req.params.eventId)
const registrationId = parseInt(req.params.registrationId)
const [ event, registration ] = await Promise.all([
loadEventWithChannel(eventId),
EventRegistrationModel.loadByIdAndEventId(registrationId, eventId)
])
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
if (!registration) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Registration not found'
})
}
const resLocals = res.locals as any
resLocals.event = event
resLocals.eventRegistration = registration
return next()
}
]
export const deleteEventRegistrationValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
param('registrationId')
.custom(isIdValid)
.withMessage('Invalid registration id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const eventId = parseInt(req.params.eventId)
const registrationId = parseInt(req.params.registrationId)
const [ event, registration ] = await Promise.all([
loadEventWithChannel(eventId),
EventRegistrationModel.loadByIdAndEventId(registrationId, eventId)
])
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
if (!registration) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Registration not found'
})
}
const resLocals = res.locals as any
resLocals.event = event
resLocals.eventRegistration = registration
return next()
}
]

View File

@@ -0,0 +1,254 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { EventModel } from '@server/models/video/event.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import express from 'express'
import { body, param } from 'express-validator'
import { areValidationErrors } from './shared/utils.js'
export const createEventValidator = [
body('title')
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Event title must be between 1 and 255 characters'),
body('description')
.optional()
.trim()
.isLength({ max: 5000 })
.withMessage('Event description must not exceed 5000 characters'),
body('startTime')
.isISO8601()
.withMessage('Start time must be a valid ISO 8601 date'),
body('endTime')
.isISO8601()
.withMessage('End time must be a valid ISO 8601 date'),
body('state')
.optional()
.isIn([ 'active', 'disabled' ])
.withMessage('Invalid event state'),
body('liveUrl')
.optional({ checkFalsy: true})
.isLength({ max: 2048 })
.withMessage('Live URL must not exceed 2048 characters')
.bail()
.isURL()
.withMessage('Live URL must be a valid URL'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
// Validate end time is after start time
const startTime = new Date(req.body.startTime)
const endTime = new Date(req.body.endTime)
if (endTime <= startTime) {
return res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'End time must be after start time'
})
}
// Validate channel exists
const { channelId } = req.params
const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId)
if (!channel) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Channel not found'
})
}
const resLocals = res.locals as any
resLocals.channel = channel
return next()
}
]
export const updateEventValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
body('title')
.optional()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Event title must be between 1 and 255 characters'),
body('description')
.optional()
.trim()
.isLength({ max: 5000 })
.withMessage('Event description must not exceed 5000 characters'),
body('startTime')
.optional()
.isISO8601()
.withMessage('Start time must be a valid ISO 8601 date'),
body('endTime')
.optional()
.isISO8601()
.withMessage('End time must be a valid ISO 8601 date'),
body('state')
.optional()
.isIn([ 'active', 'disabled' ])
.withMessage('Invalid event state'),
body('liveUrl')
.optional({ checkFalsy: true})
.isLength({ max: 2048 })
.withMessage('Live URL must not exceed 2048 characters')
.bail()
.isURL()
.withMessage('Live URL must be a valid URL'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
// Validate time range if both provided
if (req.body.startTime && req.body.endTime) {
const startTime = new Date(req.body.startTime)
const endTime = new Date(req.body.endTime)
if (endTime <= startTime) {
return res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'End time must be after start time'
})
}
}
// Validate channel exists
const { channelId, eventId } = req.params
const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId)
if (!channel) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Channel not found'
})
}
// Validate event exists and belongs to channel
const event = await EventModel.findByIdAndChannelId(parseInt(eventId), parseInt(channelId))
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
const resLocals = res.locals as any
resLocals.channel = channel
resLocals.event = event
return next()
}
]
export const deleteEventValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { channelId, eventId } = req.params
const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId)
if (!channel) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Channel not found'
})
}
const event = await EventModel.findByIdAndChannelId(parseInt(eventId), parseInt(channelId))
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
const resLocals = res.locals as any
resLocals.channel = channel
resLocals.event = event
return next()
}
]
export const updateEventStateValidator = [
param('eventId')
.custom(isIdValid)
.withMessage('Invalid event id'),
body('state')
.isIn([ 'active', 'disabled' ])
.withMessage('Invalid event state'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { channelId, eventId } = req.params
const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId)
if (!channel) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Channel not found'
})
}
const event = await EventModel.findByIdAndChannelId(parseInt(eventId), parseInt(channelId))
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
const resLocals = res.locals as any
resLocals.channel = channel
resLocals.event = event
return next()
}
]
export const getEventValidator = [
param('id')
.custom(isIdValid)
.withMessage('Invalid event id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const event = await EventModel.findByPk(req.params.id)
if (!event) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Event not found'
})
}
(res.locals as any).event = event
return next()
}
]

View File

@@ -0,0 +1,15 @@
import * as express from 'express'
const methodsValidator = (methods: string[]) => {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (methods.includes(req.method) !== true) {
return res.sendStatus(405)
}
return next()
}
}
export {
methodsValidator
}

View File

@@ -0,0 +1,169 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { param, query } from 'express-validator'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
import {
areValidationErrors,
checkCanSeeVideo,
doesAccountHandleExist,
doesAccountIdExist,
doesChannelHandleExist,
doesChannelIdExist,
doesUserFeedTokenCorrespond,
doesVideoExist
} from './shared/index.js'
export const feedsFormatValidator = [
param('format')
.optional()
.custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
query('format')
.optional()
.custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export function setFeedFormatContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
const format = req.query.format || req.params.format || 'rss'
let acceptableContentTypes: string[]
if (format === 'atom' || format === 'atom1') {
acceptableContentTypes = [ 'application/atom+xml', 'application/xml', 'text/xml' ]
} else if (format === 'json' || format === 'json1') {
acceptableContentTypes = [ 'application/json' ]
} else if (format === 'rss' || format === 'rss2') {
acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]
} else {
acceptableContentTypes = [ 'application/xml', 'text/xml' ]
}
return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}
export function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]
return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}
export function feedContentTypeResponse (
req: express.Request,
res: express.Response,
next: express.NextFunction,
acceptableContentTypes: string[]
) {
if (req.accepts(acceptableContentTypes)) {
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
} else {
return res.fail({
status: HttpStatusCode.NOT_ACCEPTABLE_406,
message: `You should accept at least one of the following content-types: ${acceptableContentTypes.join(', ')}`
})
}
return next()
}
// ---------------------------------------------------------------------------
export const feedsAccountOrChannelFiltersValidator = [
query('accountId')
.optional()
.custom(isIdValid),
query('accountName')
.optional(),
query('videoChannelId')
.optional()
.custom(isIdValid),
query('videoChannelName')
.optional(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { accountId, videoChannelId, accountName, videoChannelName } = req.query
const commonOptions = { req, res, checkCanManage: false, checkIsLocal: false, checkIsOwner: false }
if (accountId && !await doesAccountIdExist({ id: accountId, ...commonOptions })) return
if (videoChannelId && !await doesChannelIdExist({ id: videoChannelId, ...commonOptions })) return
if (accountName && !await doesAccountHandleExist({ handle: accountName, ...commonOptions })) return
if (videoChannelName && !await doesChannelHandleExist({ handle: videoChannelName, ...commonOptions })) return
return next()
}
]
// ---------------------------------------------------------------------------
export const videoFeedsPodcastValidator = [
query('videoChannelId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (
!await doesChannelIdExist({
id: req.query.videoChannelId,
checkCanManage: false,
checkIsLocal: false,
checkIsOwner: false,
req,
res
})
) return
return next()
}
]
// ---------------------------------------------------------------------------
export const videoSubscriptionFeedsValidator = [
query('accountId')
.custom(isIdValid),
query('token')
.custom(exists),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesAccountIdExist({ id: req.query.accountId, req, res, checkIsLocal: true, checkCanManage: false })) return
if (!await doesUserFeedTokenCorrespond({ id: res.locals.account.userId, token: req.query.token, req, res })) return
return next()
}
]
export const videoCommentsFeedsValidator = [
query('videoId')
.optional()
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.videoId && (req.query.videoChannelId || req.query.videoChannelName)) {
return res.fail({ message: 'videoId cannot be mixed with a channel filter' })
}
if (req.query.videoId) {
if (!await doesVideoExist(req.query.videoId, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.query.videoId, video: res.locals.videoAll })) return
}
return next()
}
]

View File

@@ -0,0 +1,156 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { HttpStatusCode, ServerFollowCreate } from '@peertube/peertube-models'
import { isProdInstance } from '@peertube/peertube-node-utils'
import { isEachUniqueHandleValid, isFollowStateValid, isRemoteHandleValid } from '@server/helpers/custom-validators/follows.js'
import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors/index.js'
import { getRemoteNameAndHost } from '@server/lib/activitypub/follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { MActorFollowActorsDefault } from '@server/types/models/index.js'
import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor.js'
import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers.js'
import { logger } from '../../helpers/logger.js'
import { WEBSERVER } from '../../initializers/constants.js'
import { ActorFollowModel } from '../../models/actor/actor-follow.js'
import { ActorModel } from '../../models/actor/actor.js'
import { areValidationErrors } from './shared/index.js'
const listFollowsValidator = [
query('state')
.optional()
.custom(isFollowStateValid),
query('actorType')
.optional()
.custom(isActorTypeValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const followValidator = [
body('hosts')
.toArray()
.custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'),
body('handles')
.toArray()
.custom(isEachUniqueHandleValid).withMessage('Should have an array of handles'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
// Force https if the administrator wants to follow remote actors
if (isProdInstance() && WEBSERVER.SCHEME === 'http') {
return res
.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500)
.json({
error: 'Cannot follow on a non HTTPS web server.'
})
}
if (areValidationErrors(req, res)) return
const body: ServerFollowCreate = req.body
if (body.hosts.length === 0 && body.handles.length === 0) {
return res
.status(HttpStatusCode.BAD_REQUEST_400)
.json({
error: 'You must provide at least one handle or one host.'
})
}
return next()
}
]
const removeFollowingValidator = [
param('hostOrHandle')
.custom(value => isHostValid(value) || isRemoteHandleValid(value)),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const serverActor = await getServerActor()
const { name, host } = getRemoteNameAndHost(req.params.hostOrHandle)
const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI({
actorId: serverActor.id,
targetName: name,
targetHost: host
})
if (!follow) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Follow ${req.params.hostOrHandle} not found.`
})
}
res.locals.follow = follow
return next()
}
]
const getFollowerValidator = [
param('handle')
.custom(isValidActorHandle),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
let follow: MActorFollowActorsDefault
try {
const actorUrl = await loadActorUrlOrGetFromWebfinger(req.params.handle)
const actor = await ActorModel.loadByUrl(actorUrl)
const serverActor = await getServerActor()
follow = await ActorFollowModel.loadByActorAndTarget(actor.id, serverActor.id)
} catch (err) {
logger.warn('Cannot get actor from handle.', { handle: req.params.handle, err })
}
if (!follow) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Follower ${req.params.handle} not found.`
})
}
res.locals.follow = follow
return next()
}
]
const acceptFollowerValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const follow = res.locals.follow
if (follow.state !== 'pending' && follow.state !== 'rejected') {
return res.fail({ message: 'Follow is not in pending/rejected state.' })
}
return next()
}
]
const rejectFollowerValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const follow = res.locals.follow
if (follow.state !== 'pending' && follow.state !== 'accepted') {
return res.fail({ message: 'Follow is not in pending/accepted state.' })
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
followValidator,
removeFollowingValidator,
getFollowerValidator,
acceptFollowerValidator,
rejectFollowerValidator,
listFollowsValidator
}

View File

@@ -0,0 +1,30 @@
export * from './abuse.js'
export * from './account.js'
export * from './activitypub/index.js'
export * from './actor-image.js'
export * from './blocklist.js'
export * from './bulk.js'
export * from './config.js'
export * from './events.js'
export * from './event-registrations.js'
export * from './express.js'
export * from './feeds.js'
export * from './follows.js'
export * from './jobs.js'
export * from './logs.js'
export * from './metrics.js'
export * from './object-storage-proxy.js'
export * from './oembed.js'
export * from './pagination.js'
export * from './plugins.js'
export * from './redundancy.js'
export * from './resumable-upload.js'
export * from './search.js'
export * from './server.js'
export * from './sort.js'
export * from './static.js'
export * from './themes.js'
export * from './webfinger.js'
export * from './users/index.js'
export * from './videos/index.js'
export * from './runners/index.js'

View File

@@ -0,0 +1,29 @@
import express from 'express'
import { param, query } from 'express-validator'
import { isValidJobState, isValidJobType } from '../../helpers/custom-validators/jobs.js'
import { loggerTagsFactory } from '../../helpers/logger.js'
import { areValidationErrors } from './shared/index.js'
const lTags = loggerTagsFactory('validators', 'jobs')
const listJobsValidator = [
param('state')
.optional()
.custom(isValidJobState),
query('jobType')
.optional()
.custom(isValidJobType),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, lTags())) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
listJobsValidator
}

View File

@@ -0,0 +1,247 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { isVideoFileMimeTypeValid, isVideoImageValid } from '@server/helpers/custom-validators/videos.js'
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { areValidationErrors } from './shared/utils.js'
function ensureAdmin (res: express.Response) {
const user = res.locals.oauth?.token?.User
return user?.role === 0
}
async function checkScheduleConflict (req: express.Request, res: express.Response, next: express.NextFunction, excludeId?: number) {
const startTime = new Date(req.body.startTime)
const endTime = new Date(req.body.endTime)
const conflicts = await LiveVideoModel.listConflicts({
targetCamera: req.body.targetCamera,
startTime,
endTime,
excludeId
})
if (conflicts.length !== 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Target camera is already assigned to another live video'
})
}
return next()
}
export const listLiveVideosValidator = [
query('status')
.optional()
.isIn(Object.values(LiveVideoStatus))
.withMessage('Invalid live video status'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const getLiveVideoValidator = [
param('id')
.custom(isIdValid)
.withMessage('Invalid live video id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const liveVideo = await LiveVideoModel.loadById(parseInt(req.params.id))
if (!liveVideo) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Live video not found'
})
}
;(res.locals as any).liveVideo = liveVideo
return next()
}
]
export const createLiveVideoValidator = [
body('title')
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Live video title must be between 1 and 255 characters'),
body('startTime')
.isISO8601()
.withMessage('Start time must be a valid ISO 8601 date'),
body('endTime')
.isISO8601()
.withMessage('End time must be a valid ISO 8601 date'),
body('targetCamera')
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Target camera is required'),
body('thumbnailfile')
.custom((_, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
.withMessage('Should have a valid thumbnail file'),
body('videofile')
.custom((_, { req }) => isVideoFileMimeTypeValid(req.files))
.withMessage('Should have a valid video file'),
body('subtitlefile')
.optional()
.custom((_, { req }) => {
if (!req.files || !req.files['subtitlefile']) return true
const file = req.files['subtitlefile'][0]
const validMimes = [ 'text/vtt', 'application/x-subrip', 'text/plain' ]
return validMimes.includes(file.mimetype)
})
.withMessage('Should have a valid subtitle file (vtt or srt)'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureAdmin(res)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can manage live videos'
})
}
const startTime = new Date(req.body.startTime)
const endTime = new Date(req.body.endTime)
if (endTime <= startTime) {
return res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'End time must be after start time'
})
}
return checkScheduleConflict(req, res, next)
}
]
export const updateLiveVideoValidator = [
param('id')
.custom(isIdValid)
.withMessage('Invalid live video id'),
body('title')
.optional()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Live video title must be between 1 and 255 characters'),
body('startTime')
.optional()
.isISO8601()
.withMessage('Start time must be a valid ISO 8601 date'),
body('endTime')
.optional()
.isISO8601()
.withMessage('End time must be a valid ISO 8601 date'),
body('targetCamera')
.optional()
.trim()
.isLength({ min: 1, max: 255 })
.withMessage('Target camera must be between 1 and 255 characters'),
body('status')
.optional()
.isIn(Object.values(LiveVideoStatus))
.withMessage('Invalid live video status'),
body('thumbnailfile')
.optional()
.custom((_, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
.withMessage('Should have a valid thumbnail file'),
body('videofile')
.optional()
.custom((_, { req }) => isVideoFileMimeTypeValid(req.files))
.withMessage('Should have a valid video file'),
body('subtitlefile')
.optional()
.custom((_, { req }) => {
if (!req.files || !req.files['subtitlefile']) return true
const file = req.files['subtitlefile'][0]
const validMimes = [ 'text/vtt', 'application/x-subrip', 'text/plain' ]
return validMimes.includes(file.mimetype)
})
.withMessage('Should have a valid subtitle file (vtt or srt)'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureAdmin(res)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can manage live videos'
})
}
const liveVideo = await LiveVideoModel.loadById(parseInt(req.params.id))
if (!liveVideo) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Live video not found'
})
}
const startTime = req.body.startTime ? new Date(req.body.startTime) : liveVideo.startTime
const endTime = req.body.endTime ? new Date(req.body.endTime) : liveVideo.endTime
if (endTime <= startTime) {
return res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'End time must be after start time'
})
}
;(res.locals as any).liveVideo = liveVideo
if (req.body.startTime || req.body.endTime || req.body.targetCamera) {
req.body.startTime = startTime.toISOString()
req.body.endTime = endTime.toISOString()
req.body.targetCamera = req.body.targetCamera ?? liveVideo.targetCamera
return checkScheduleConflict(req, res, next, liveVideo.id)
}
return next()
}
]
export const deleteLiveVideoValidator = [
param('id')
.custom(isIdValid)
.withMessage('Invalid live video id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureAdmin(res)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can manage live videos'
})
}
const liveVideo = await LiveVideoModel.loadById(parseInt(req.params.id))
if (!liveVideo) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Live video not found'
})
}
;(res.locals as any).liveVideo = liveVideo
return next()
}
]

View File

@@ -0,0 +1,93 @@
import express from 'express'
import { body, query } from 'express-validator'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
import { CONFIG } from '@server/initializers/config.js'
import { arrayify } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import {
isValidClientLogLevel,
isValidClientLogMessage,
isValidClientLogMeta,
isValidClientLogStackTrace,
isValidClientLogUserAgent,
isValidLogLevel
} from '../../helpers/custom-validators/logs.js'
import { isDateValid } from '../../helpers/custom-validators/misc.js'
import { areValidationErrors } from './shared/index.js'
const createClientLogValidator = [
body('message')
.custom(isValidClientLogMessage),
body('url')
.custom(isUrlValid),
body('level')
.custom(isValidClientLogLevel),
body('stackTrace')
.optional()
.custom(isValidClientLogStackTrace),
body('meta')
.optional()
.custom(isValidClientLogMeta),
body('userAgent')
.optional()
.custom(isValidClientLogUserAgent),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (CONFIG.LOG.ACCEPT_CLIENT_LOG !== true) {
return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
}
if (areValidationErrors(req, res)) return
return next()
}
]
const getLogsValidator = [
query('startDate')
.custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
query('level')
.optional()
.custom(isValidLogLevel),
query('tagsOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid tags one of array'),
query('endDate')
.optional()
.custom(isDateValid).withMessage('Should have an end date that conforms to ISO 8601'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const getAuditLogsValidator = [
query('startDate')
.custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
query('endDate')
.optional()
.custom(isDateValid).withMessage('Should have a end date that conforms to ISO 8601'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
getLogsValidator,
getAuditLogsValidator,
createClientLogValidator
}

View File

@@ -0,0 +1,64 @@
import express from 'express'
import { body } from 'express-validator'
import { isValidPlayerMode } from '@server/helpers/custom-validators/metrics.js'
import { isIdOrUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { HttpStatusCode, PlaybackMetricCreate } from '@peertube/peertube-models'
import { areValidationErrors, doesVideoExist } from './shared/index.js'
const addPlaybackMetricValidator = [
body('resolution')
.isInt({ min: 0 }),
body('fps')
.optional()
.isInt({ min: 0 }),
body('p2pPeers')
.optional()
.isInt({ min: 0 }),
body('p2pEnabled')
.isBoolean(),
body('playerMode')
.custom(isValidPlayerMode),
body('resolutionChanges')
.isInt({ min: 0 }),
body('bufferStalled')
.optional()
.isInt({ min: 0 }),
body('errors')
.isInt({ min: 0 }),
body('downloadedBytesP2P')
.isInt({ min: 0 }),
body('downloadedBytesHTTP')
.isInt({ min: 0 }),
body('uploadedBytesP2P')
.isInt({ min: 0 }),
body('videoId')
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!CONFIG.OPEN_TELEMETRY.METRICS.ENABLED) return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
const body: PlaybackMetricCreate = req.body
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(body.videoId, res, 'unsafe-only-immutable-attributes')) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
addPlaybackMetricValidator
}

View File

@@ -0,0 +1,20 @@
import express from 'express'
import { CONFIG } from '@server/initializers/config.js'
import { HttpStatusCode } from '@peertube/peertube-models'
const ensurePrivateObjectStorageProxyIsEnabled = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES !== true) {
return res.fail({
message: 'Private object storage proxy is not enabled',
status: HttpStatusCode.BAD_REQUEST_400
})
}
return next()
}
]
export {
ensurePrivateObjectStorageProxyIsEnabled
}

View File

@@ -0,0 +1,157 @@
import express from 'express'
import { query } from 'express-validator'
import { join } from 'path'
import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
import { loadVideo } from '@server/lib/model-loaders/index.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { isIdOrUUIDValid, isUUIDValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
import { WEBSERVER } from '../../initializers/constants.js'
import { areValidationErrors } from './shared/index.js'
const playlistPaths = [
join('videos', 'watch', 'playlist'),
join('w', 'p')
]
const videoPaths = [
join('videos', 'watch'),
'w'
]
function buildUrls (paths: string[]) {
return paths.map(p => WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, p) + '/')
}
const startPlaylistURLs = buildUrls(playlistPaths)
const startVideoURLs = buildUrls(videoPaths)
const isURLOptions = {
require_host: true,
require_tld: true
}
// We validate 'localhost', so we don't have the top level domain
if (isTestOrDevInstance()) {
isURLOptions.require_tld = false
}
const oembedValidator = [
query('url')
.isURL(isURLOptions),
query('maxwidth')
.optional()
.isInt(),
query('maxheight')
.optional()
.isInt(),
query('format')
.optional()
.isIn([ 'xml', 'json' ]),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.format !== undefined && req.query.format !== 'json') {
return res.fail({
status: HttpStatusCode.NOT_IMPLEMENTED_501,
message: 'Requested format is not implemented on server.',
data: {
format: req.query.format
}
})
}
const url = req.query.url as string
let urlPath: string
try {
urlPath = new URL(url).pathname
} catch (err) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: err.message,
data: {
url
}
})
}
const isPlaylist = startPlaylistURLs.some(u => url.startsWith(u))
const isVideo = isPlaylist ? false : startVideoURLs.some(u => url.startsWith(u))
const startIsOk = isVideo || isPlaylist
const parts = urlPath.split('/')
if (startIsOk === false || parts.length === 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid url.',
data: {
url
}
})
}
const elementId = toCompleteUUID(parts.pop())
if (isIdOrUUIDValid(elementId) === false) {
return res.fail({ message: 'Invalid video or playlist id.' })
}
if (isVideo) {
const video = await loadVideo(elementId, 'all')
if (!video) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video not found'
})
}
if (
video.privacy === VideoPrivacy.PUBLIC ||
(video.privacy === VideoPrivacy.UNLISTED && isUUIDValid(elementId) === true)
) {
res.locals.videoAll = video
return next()
}
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Video is not publicly available'
})
}
// Is playlist
const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannelSummary(elementId, undefined)
if (!videoPlaylist) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video playlist not found'
})
}
if (
videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC ||
(videoPlaylist.privacy === VideoPlaylistPrivacy.UNLISTED && isUUIDValid(elementId))
) {
res.locals.videoPlaylistSummary = videoPlaylist
return next()
}
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Playlist is not public'
})
}
]
// ---------------------------------------------------------------------------
export {
oembedValidator
}

View File

@@ -0,0 +1,30 @@
import express from 'express'
import { query } from 'express-validator'
import { PAGINATION } from '@server/initializers/constants.js'
import { areValidationErrors } from './shared/index.js'
const paginationValidator = paginationValidatorBuilder()
function paginationValidatorBuilder (tags: string[] = []) {
return [
query('start')
.optional()
.isInt({ min: 0 }),
query('count')
.optional()
.isInt({ min: 0, max: PAGINATION.GLOBAL.COUNT.MAX }).withMessage(`Should have a number count (max: ${PAGINATION.GLOBAL.COUNT.MAX})`),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
return next()
}
]
}
// ---------------------------------------------------------------------------
export {
paginationValidator,
paginationValidatorBuilder
}

View File

@@ -0,0 +1,117 @@
import { UserRight } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { isPlayerChannelThemeSettingValid, isPlayerVideoThemeSettingValid } from '@server/helpers/custom-validators/player-settings.js'
import express from 'express'
import { body, query } from 'express-validator'
import { areValidationErrors, isValidVideoIdParam } from './shared/utils.js'
import { checkCanManageChannel } from './shared/video-channels.js'
import { checkCanManageVideo, checkCanSeeVideo, doesVideoExist } from './shared/videos.js'
export const getVideoPlayerSettingsValidator = [
isValidVideoIdParam('videoId'),
query('raw')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const raw = req.query.raw === true
if (!await doesVideoExist(req.params.videoId, res, raw ? 'all' : 'only-video-and-blacklist')) return
const video = res.locals.onlyVideo || res.locals.videoAll
if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return
if (raw === true) {
const user = res.locals.oauth?.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
}
return next()
}
]
export const getChannelPlayerSettingsValidator = [
query('raw')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.raw === true) {
const user = res.locals.oauth?.token.User
if (
!await checkCanManageChannel({
channel: res.locals.videoChannel,
user,
req,
res,
checkCanManage: true,
checkIsOwner: false,
specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL
})
) {
return false
}
}
return next()
}
]
export const updateVideoPlayerSettingsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const updatePlayerSettingsValidatorFactory = (type: 'video' | 'channel') => [
body('theme')
.custom(v => {
return type === 'video'
? isPlayerVideoThemeSettingValid(v)
: isPlayerChannelThemeSettingValid(v)
}),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]

View File

@@ -0,0 +1,204 @@
import { HttpStatusCode, InstallOrUpdatePlugin, PluginType_Type } from '@peertube/peertube-models'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
exists,
isBooleanValid,
isSafePath,
isStableOrUnstableVersionValid,
toBooleanOrNull,
toIntOrNull
} from '../../helpers/custom-validators/misc.js'
import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid } from '../../helpers/custom-validators/plugins.js'
import { CONFIG } from '../../initializers/config.js'
import { PluginManager } from '../../lib/plugins/plugin-manager.js'
import { PluginModel } from '../../models/server/plugin.js'
import { areValidationErrors } from './shared/index.js'
export const getPluginValidator = (pluginType: PluginType_Type, withVersion = true) => {
const validators: (ValidationChain | express.Handler)[] = [
param('pluginName')
.custom(isPluginNameValid)
]
if (withVersion) {
validators.push(
param('pluginVersion')
.custom(isStableOrUnstableVersionValid)
)
}
return validators.concat([
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType)
const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName)
if (!plugin) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No plugin found named ' + npmName
})
}
if (withVersion && plugin.version !== req.params.pluginVersion) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No plugin found named ' + npmName + ' with version ' + req.params.pluginVersion
})
}
res.locals.registeredPlugin = plugin
return next()
}
])
}
export const getExternalAuthValidator = [
param('authName')
.custom(exists),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const plugin = res.locals.registeredPlugin
if (!plugin.registerHelpers) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No registered helpers were found for this plugin'
})
}
const externalAuth = plugin.registerHelpers.getExternalAuths().find(a => a.authName === req.params.authName)
if (!externalAuth) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No external auths were found for this plugin'
})
}
res.locals.externalAuth = externalAuth
return next()
}
]
export const pluginStaticDirectoryValidator = [
param('staticEndpoint')
.custom(isSafePath),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const listPluginsValidator = [
query('pluginType')
.optional()
.customSanitizer(toIntOrNull)
.custom(isPluginTypeValid),
query('uninstalled')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const installOrUpdatePluginValidator = [
body('npmName')
.optional()
.custom(isNpmPluginNameValid),
body('pluginVersion')
.optional()
.custom(isStableOrUnstableVersionValid),
body('path')
.optional()
.custom(isSafePath),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: InstallOrUpdatePlugin = req.body
if (!body.path && !body.npmName) {
return res.fail({ message: 'Should have either a npmName or a path' })
}
if (body.pluginVersion && !body.npmName) {
return res.fail({ message: 'Should have a npmName when specifying a pluginVersion' })
}
return next()
}
]
export const uninstallPluginValidator = [
body('npmName')
.custom(isNpmPluginNameValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const existingPluginValidator = [
param('npmName')
.custom(isNpmPluginNameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const plugin = await PluginModel.loadByNpmName(req.params.npmName)
if (!plugin) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Plugin not found'
})
}
res.locals.plugin = plugin
return next()
}
]
export const updatePluginSettingsValidator = [
body('settings')
.exists(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const listAvailablePluginsValidator = [
query('search')
.optional()
.exists(),
query('pluginType')
.optional()
.customSanitizer(toIntOrNull)
.custom(isPluginTypeValid),
query('currentPeerTubeEngine')
.optional()
.custom(isStableOrUnstableVersionValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.PLUGINS.INDEX.ENABLED === false) {
return res.fail({ message: 'Plugin index is not enabled' })
}
return next()
}
]

View File

@@ -0,0 +1,154 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isVideoRedundancyTarget } from '@server/helpers/custom-validators/video-redundancies.js'
import {
exists,
isBooleanValid,
isIdOrUUIDValid,
isIdValid,
toBooleanOrNull,
toCompleteUUID,
toIntOrNull
} from '../../helpers/custom-validators/misc.js'
import { isHostValid } from '../../helpers/custom-validators/servers.js'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js'
import { ServerModel } from '../../models/server/server.js'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from './shared/index.js'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
const videoPlaylistRedundancyGetValidator = [
isValidVideoIdParam('videoId'),
param('streamingPlaylistType')
.customSanitizer(toIntOrNull)
.custom(exists),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (!canVideoBeFederated(video)) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const paramPlaylistType = req.params.streamingPlaylistType as unknown as number // We casted to int above
const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p.type === paramPlaylistType)
if (!videoStreamingPlaylist) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video playlist not found.'
})
}
res.locals.videoStreamingPlaylist = videoStreamingPlaylist
const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id)
if (!videoRedundancy) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video redundancy not found.'
})
}
res.locals.videoRedundancy = videoRedundancy
return next()
}
]
const updateServerRedundancyValidator = [
param('host')
.custom(isHostValid),
body('redundancyAllowed')
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid redundancyAllowed boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const server = await ServerModel.loadByHost(req.params.host)
if (!server) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Server ${req.params.host} not found.`
})
}
res.locals.server = server
return next()
}
]
const listVideoRedundanciesValidator = [
query('target')
.custom(isVideoRedundancyTarget),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const addVideoRedundancyValidator = [
body('videoId')
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.body.videoId, res, 'only-video-and-blacklist')) return
if (res.locals.onlyVideo.remote === false) {
return res.fail({ message: 'Cannot create a redundancy on a local video' })
}
if (res.locals.onlyVideo.isLive) {
return res.fail({ message: 'Cannot create a redundancy of a live video' })
}
const alreadyExists = await VideoRedundancyModel.isLocalByVideoUUIDExists(res.locals.onlyVideo.uuid)
if (alreadyExists) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'This video is already duplicated by your instance.'
})
}
return next()
}
]
const removeVideoRedundancyValidator = [
param('redundancyId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const redundancy = await VideoRedundancyModel.loadByIdWithVideo(forceNumber(req.params.redundancyId))
if (!redundancy) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video redundancy not found'
})
}
res.locals.videoRedundancy = redundancy
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoPlaylistRedundancyGetValidator,
updateServerRedundancyValidator,
listVideoRedundanciesValidator,
addVideoRedundancyValidator,
removeVideoRedundancyValidator
}

View File

@@ -0,0 +1,36 @@
import { logger } from '@server/helpers/logger.js'
import express from 'express'
import { body, header } from 'express-validator'
import { areValidationErrors } from './shared/utils.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
export const resumableInitValidator = [
body('filename')
.exists(),
header('x-upload-content-length')
.isNumeric()
.exists()
.withMessage('Should specify the file length'),
header('x-upload-content-type')
.isString()
.exists()
.withMessage('Should specify the file mimetype'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking resumableInitValidator parameters and headers', {
parameters: req.body,
headers: req.headers
})
if (areValidationErrors(req, res, { omitLog: true })) return cleanUpReqFiles(req)
res.locals.uploadVideoFileResumableMetadata = {
mimetype: req.headers['x-upload-content-type'] as string,
size: +req.headers['x-upload-content-length'],
originalname: req.body.filename
}
return next()
}
]

View File

@@ -0,0 +1,3 @@
export * from './jobs.js'
export * from './registration-token.js'
export * from './runners.js'

View File

@@ -0,0 +1,60 @@
import express from 'express'
import { param } from 'express-validator'
import { basename } from 'path'
import { isSafeFilename } from '@server/helpers/custom-validators/misc.js'
import { hasVideoStudioTaskFile, HttpStatusCode, RunnerJobStudioTranscodingPayload } from '@peertube/peertube-models'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const tags = [ 'runner' ]
export const runnerJobGetVideoTranscodingFileValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
const runnerJob = res.locals.runnerJob
if (runnerJob.privatePayload.videoUUID !== res.locals.videoAll.uuid) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Job is not associated to this video',
tags: [ ...tags, res.locals.videoAll.uuid ]
})
}
return next()
}
]
export const runnerJobGetVideoStudioTaskFileValidator = [
param('filename').custom(v => isSafeFilename(v)),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const filename = req.params.filename
const payload = res.locals.runnerJob.payload as RunnerJobStudioTranscodingPayload
const found = Array.isArray(payload?.tasks) && payload.tasks.some(t => {
if (hasVideoStudioTaskFile(t)) {
return basename(t.options.file) === filename
}
return false
})
if (!found) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'File is not associated to this edition task',
tags: [ ...tags, res.locals.videoAll.uuid ]
})
}
return next()
}
]

View File

@@ -0,0 +1,200 @@
import { arrayify } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
RunnerJobState,
RunnerJobStateType,
RunnerJobSuccessBody,
RunnerJobUpdateBody,
ServerErrorCode
} from '@peertube/peertube-models'
import { exists, isUUIDValid } from '@server/helpers/custom-validators/misc.js'
import {
isRunnerJobAbortReasonValid,
isRunnerJobArrayOfStateValid,
isRunnerJobErrorMessageValid,
isRunnerJobProgressValid,
isRunnerJobSuccessPayloadValid,
isRunnerJobTokenValid,
isRunnerJobUpdatePayloadValid
} from '@server/helpers/custom-validators/runners/jobs.js'
import { isRunnerTokenValid } from '@server/helpers/custom-validators/runners/runners.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
import { runnerJobCanBeCancelled } from '@server/lib/runners/index.js'
import { RunnerJobModel } from '@server/models/runner/runner-job.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { areValidationErrors } from '../shared/index.js'
const tags = [ 'runner' ]
export const acceptRunnerJobValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.locals.runnerJob.state !== RunnerJobState.PENDING) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This runner job is not in pending state',
tags
})
}
return next()
}
]
export const abortRunnerJobValidator = [
body('reason').custom(isRunnerJobAbortReasonValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
return next()
}
]
export const updateRunnerJobValidator = [
body('progress').optional().custom(isRunnerJobProgressValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return cleanUpReqFiles(req)
const body = req.body as RunnerJobUpdateBody
const job = res.locals.runnerJob
if (isRunnerJobUpdatePayloadValid(body.payload, job.type, req.files) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Payload is invalid',
tags
})
}
return next()
}
]
export const errorRunnerJobValidator = [
body('message').custom(isRunnerJobErrorMessageValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
return next()
}
]
export const successRunnerJobValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const body = req.body as RunnerJobSuccessBody
if (isRunnerJobSuccessPayloadValid(body.payload, res.locals.runnerJob.type, req.files) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Payload is invalid',
tags
})
}
return next()
}
]
export const cancelRunnerJobValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const runnerJob = res.locals.runnerJob
if (runnerJobCanBeCancelled(runnerJob) !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot cancel this job that is not in "pending", "processing" or "waiting for parent job" state',
tags
})
}
return next()
}
]
export const listRunnerJobsValidator = [
query('search')
.optional()
.custom(exists),
query('stateOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isRunnerJobArrayOfStateValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
return next()
}
]
export const runnerJobGetValidator = [
param('jobUUID').custom(isUUIDValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const runnerJob = await RunnerJobModel.loadWithRunner(req.params.jobUUID)
if (!runnerJob) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Unknown runner job',
tags
})
}
res.locals.runnerJob = runnerJob
return next()
}
]
export function jobOfRunnerGetValidatorFactory (allowedStates: RunnerJobStateType[]) {
return [
param('jobUUID').custom(isUUIDValid),
body('runnerToken').custom(isRunnerTokenValid),
body('jobToken').custom(isRunnerJobTokenValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return cleanUpReqFiles(req)
const runnerJob = await RunnerJobModel.loadByRunnerAndJobTokensWithRunner({
uuid: req.params.jobUUID,
runnerToken: req.body.runnerToken,
jobToken: req.body.jobToken
})
if (!runnerJob) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Unknown runner job',
tags
})
}
if (!allowedStates.includes(runnerJob.state)) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
type: ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE,
message: 'Job is not in "processing" state',
tags
})
}
res.locals.runnerJob = runnerJob
return next()
}
]
}

View File

@@ -0,0 +1,37 @@
import express from 'express'
import { param } from 'express-validator'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token.js'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { areValidationErrors } from '../shared/utils.js'
const tags = [ 'runner' ]
const deleteRegistrationTokenValidator = [
param('id').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const registrationToken = await RunnerRegistrationTokenModel.load(forceNumber(req.params.id))
if (!registrationToken) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Registration token not found',
tags
})
}
res.locals.runnerRegistrationToken = registrationToken
return next()
}
]
// ---------------------------------------------------------------------------
export {
deleteRegistrationTokenValidator
}

View File

@@ -0,0 +1,108 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, RegisterRunnerBody, ServerErrorCode } from '@peertube/peertube-models'
import { isIdValid, isStableOrUnstableVersionValid } from '@server/helpers/custom-validators/misc.js'
import {
isRunnerDescriptionValid,
isRunnerNameValid,
isRunnerRegistrationTokenValid,
isRunnerTokenValid
} from '@server/helpers/custom-validators/runners/runners.js'
import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import express from 'express'
import { body, param } from 'express-validator'
import { areValidationErrors } from '../shared/utils.js'
const tags = [ 'runner' ]
export const registerRunnerValidator = [
body('registrationToken').custom(isRunnerRegistrationTokenValid),
body('name').custom(isRunnerNameValid),
body('description').optional().custom(isRunnerDescriptionValid),
body('version').optional().custom(isStableOrUnstableVersionValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const body: RegisterRunnerBody = req.body
const runnerRegistrationToken = await RunnerRegistrationTokenModel.loadByRegistrationToken(body.registrationToken)
if (!runnerRegistrationToken) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Registration token is invalid',
tags
})
}
const existing = await RunnerModel.loadByName(body.name)
if (existing) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This runner name already exists on this instance',
tags
})
}
res.locals.runnerRegistrationToken = runnerRegistrationToken
return next()
}
]
export const deleteRunnerValidator = [
param('runnerId').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const runner = await RunnerModel.load(forceNumber(req.params.runnerId))
if (!runner) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Runner not found',
tags
})
}
res.locals.runner = runner
return next()
}
]
export const getRunnerFromTokenValidator = [
body('runnerToken').custom(isRunnerTokenValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const runner = await RunnerModel.loadByToken(req.body.runnerToken)
if (!runner) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Unknown runner token',
type: ServerErrorCode.UNKNOWN_RUNNER_TOKEN,
tags
})
}
res.locals.runner = runner
return next()
}
]
export const requestRunnerJobValidator = [
body('version').optional().custom(isStableOrUnstableVersionValid),
body('jobTypes').optional().isArray(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
return next()
}
]

View File

@@ -0,0 +1,108 @@
import express from 'express'
import { query } from 'express-validator'
import { isSearchTargetValid } from '@server/helpers/custom-validators/search.js'
import { isHostValid } from '@server/helpers/custom-validators/servers.js'
import { areUUIDsValid, isDateValid, isNotEmptyStringArray, toCompleteUUIDs } from '../../helpers/custom-validators/misc.js'
import { areValidationErrors } from './shared/index.js'
const videosSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
query('startDate')
.optional()
.custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
query('endDate')
.optional()
.custom(isDateValid).withMessage('Should have a end date that conforms to ISO 8601'),
query('originallyPublishedStartDate')
.optional()
.custom(isDateValid).withMessage('Should have a published start date that conforms to ISO 8601'),
query('originallyPublishedEndDate')
.optional()
.custom(isDateValid).withMessage('Should have a published end date that conforms to ISO 8601'),
query('durationMin')
.optional()
.isInt(),
query('durationMax')
.optional()
.isInt(),
query('uuids')
.optional()
.toArray()
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid array of uuid'),
query('searchTarget')
.optional()
.custom(isSearchTargetValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const videoChannelsListSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
query('host')
.optional()
.custom(isHostValid),
query('searchTarget')
.optional()
.custom(isSearchTargetValid),
query('handles')
.optional()
.toArray()
.custom(isNotEmptyStringArray).withMessage('Should have valid array of handles'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const videoPlaylistsListSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
query('host')
.optional()
.custom(isHostValid),
query('searchTarget')
.optional()
.custom(isSearchTargetValid),
query('uuids')
.optional()
.toArray()
.customSanitizer(toCompleteUUIDs)
.custom(areUUIDsValid).withMessage('Should have valid array of uuid'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosSearchValidator,
videoChannelsListSearchValidator,
videoPlaylistsListSearchValidator
}

View File

@@ -0,0 +1,75 @@
import express from 'express'
import { body } from 'express-validator'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers.js'
import { isUserDisplayNameValid } from '../../helpers/custom-validators/users.js'
import { logger } from '../../helpers/logger.js'
import { CONFIG, isEmailEnabled } from '../../initializers/config.js'
import { Redis } from '../../lib/redis.js'
import { ServerModel } from '../../models/server/server.js'
import { areValidationErrors } from './shared/index.js'
const serverGetValidator = [
body('host').custom(isHostValid).withMessage('Should have a valid host'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const server = await ServerModel.loadByHost(req.body.host)
if (!server) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Server host not found.'
})
}
res.locals.server = server
return next()
}
]
const contactAdministratorValidator = [
body('fromName')
.custom(isUserDisplayNameValid),
body('fromEmail')
.isEmail(),
body('body')
.custom(isValidContactBody),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.CONTACT_FORM.ENABLED === false) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Contact form is not enabled on this instance.'
})
}
if (isEmailEnabled() === false) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'SMTP is not configured on this instance.'
})
}
if (await Redis.Instance.doesContactFormIpExist(req.ip)) {
logger.info('Refusing a contact form by %s: already sent one recently.', req.ip)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'You already sent a contact form recently.'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
serverGetValidator,
contactAdministratorValidator
}

View File

@@ -0,0 +1,26 @@
import { Response } from 'express'
import { AbuseModel } from '@server/models/abuse/abuse.js'
import { HttpStatusCode } from '@peertube/peertube-models'
import { forceNumber } from '@peertube/peertube-core-utils'
async function doesAbuseExist (abuseId: number | string, res: Response) {
const abuse = await AbuseModel.loadByIdWithReporter(forceNumber(abuseId))
if (!abuse) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Abuse not found'
})
return false
}
res.locals.abuse = abuse
return true
}
// ---------------------------------------------------------------------------
export {
doesAbuseExist
}

View File

@@ -0,0 +1,76 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { AccountModel } from '@server/models/account/account.js'
import { MAccountDefault } from '@server/types/models/index.js'
import { Request, Response } from 'express'
import { checkCanManageAccount } from './users.js'
export async function doesAccountIdExist (options: {
id: string | number
req: Request
res: Response
checkCanManage: boolean // Also check the user can manage the account
checkIsLocal: boolean // Also check this is a local channel
}) {
const { id, req, res, checkIsLocal, checkCanManage } = options
const account = await AccountModel.load(forceNumber(id))
return doesAccountExist({ account, req, res, checkIsLocal, checkCanManage })
}
export async function doesAccountHandleExist (options: {
handle: string
req: Request
res: Response
checkCanManage: boolean // Also check the user can manage the account
checkIsLocal: boolean // Also check this is a local channel
}) {
const { handle, req, res, checkIsLocal, checkCanManage } = options
const account = await AccountModel.loadByHandle(handle)
return doesAccountExist({ account, req, res, checkIsLocal, checkCanManage })
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function doesAccountExist (options: {
account: MAccountDefault
req: Request
res: Response
checkCanManage: boolean
checkIsLocal: boolean
}) {
const { account, req, res, checkIsLocal, checkCanManage } = options
if (!account) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Account not found')
})
return false
}
if (checkCanManage) {
const user = res.locals.oauth.token.User
if (!checkCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_USERS })) {
return false
}
}
if (checkIsLocal && account.Actor.isLocal() === false) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('This account is not owned by the platform')
})
return false
}
res.locals.account = account
return true
}

View File

@@ -0,0 +1,19 @@
import { isActorImageFile } from '@server/helpers/custom-validators/actor-images.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import express from 'express'
import { body } from 'express-validator'
import { areValidationErrors } from './utils.js'
export const updateActorImageValidatorFactory = (fieldname: string) => [
body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage(
'This file is not supported or too large. Please, make sure it is of the following type : ' +
CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME.join(', ')
),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
return next()
}
]

View File

@@ -0,0 +1,15 @@
export * from './abuses.js'
export * from './accounts.js'
export * from './images.js'
export * from './users.js'
export * from './utils.js'
export * from './video-blacklists.js'
export * from './video-captions.js'
export * from './video-channels.js'
export * from './video-channel-syncs.js'
export * from './video-comments.js'
export * from './video-imports.js'
export * from './video-ownerships.js'
export * from './video-playlists.js'
export * from './video-passwords.js'
export * from './videos.js'

View File

@@ -0,0 +1,150 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRightType } from '@peertube/peertube-models'
import { loadReservedActorName } from '@server/lib/local-actor.js'
import { getByEmailPermissive } from '@server/lib/user.js'
import { UserModel } from '@server/models/user/user.js'
import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js'
import express from 'express'
export function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
const id = forceNumber(idArg)
return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
}
export function checkUserEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkUserExist(
async () => {
const users = await UserModel.loadByEmailCaseInsensitive(email)
return getByEmailPermissive(users, email)
},
res,
abortResponse
)
}
export function checkUserPendingEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkUserExist(
async () => {
const users = await UserModel.loadByPendingEmailCaseInsensitive(email)
return getByEmailPermissive(users, email)
},
res,
abortResponse
)
}
export async function checkUsernameOrEmailDoNotAlreadyExist (options: {
username: string
email: string
req: express.Request
res: express.Response
}) {
const { username, email, req, res } = options
const existingUser = await UserModel.loadByUsernameOrEmailCaseInsensitive(username)
const existingEmail = await UserModel.loadByUsernameOrEmailCaseInsensitive(email)
if (existingUser.length > 0 || existingEmail.length > 0) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('User with this username or email already exists.')
})
return false
}
const actor = await loadReservedActorName(username)
if (actor) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Another actor (account/channel) with this name on this instance already exists or has already existed.')
})
return false
}
return true
}
export async function checkEmailDoesNotAlreadyExist (options: {
email: string
req: express.Request
res: express.Response
}) {
const { email, req, res } = options
const user = await UserModel.loadByEmailCaseInsensitive(email)
if (user.length !== 0) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('User with this email already exists.')
})
return false
}
return true
}
export async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
const user = await finder()
if (!user) {
if (abortResponse === true) {
res.sendStatus(HttpStatusCode.NOT_FOUND_404)
}
return false
}
res.locals.user = user
return true
}
export function checkCanManageAccount (options: {
user: MUserAccountId
account: MAccountId
specialRight: UserRightType
req: express.Request
res: express.Response | null
}) {
const { user, account, specialRight, res, req } = options
if (!user) {
res?.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('Authentication is required')
})
return false
}
if (account.id === user.Account.id) return true
if (specialRight && user.hasRight(specialRight) === true) return true
res?.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Only a user with sufficient right can manage this account resource.')
})
return false
}
export async function doesUserFeedTokenCorrespond (options: {
id: number
token: string
req: express.Request
res: express.Response
}) {
const { id, token, req, res } = options
const user = await UserModel.loadByIdWithChannels(forceNumber(id))
if (token !== user.feedToken) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('User and token mismatch')
})
return false
}
res.locals.user = user
return true
}

View File

@@ -0,0 +1,69 @@
import express from 'express'
import { param, validationResult } from 'express-validator'
import { isIdOrUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import { logger } from '../../../helpers/logger.js'
function areValidationErrors (
req: express.Request,
res: express.Response,
options: {
omitLog?: boolean
omitBodyLog?: boolean
tags?: (number | string)[]
} = {}) {
const { omitLog = false, omitBodyLog = false, tags = [] } = options
if (!omitLog) {
logger.debug(
'Checking %s - %s parameters',
req.method, req.originalUrl,
{
body: omitBodyLog
? 'omitted'
: req.body,
params: req.params,
query: req.query,
files: req.files,
tags
}
)
}
const errors = validationResult(req)
if (!errors.isEmpty()) {
logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
res.fail({
message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
instance: req.originalUrl,
data: {
'invalid-params': errors.mapped()
}
})
return true
}
return false
}
function isValidVideoIdParam (paramName: string) {
return param(paramName)
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id (id, short UUID or UUID)')
}
function isValidPlaylistIdParam (paramName: string) {
return param(paramName)
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id (id, short UUID or UUID)')
}
// ---------------------------------------------------------------------------
export {
areValidationErrors,
isValidVideoIdParam,
isValidPlaylistIdParam
}

View File

@@ -0,0 +1,24 @@
import { Response } from 'express'
import { VideoBlacklistModel } from '@server/models/video/video-blacklist.js'
import { HttpStatusCode } from '@peertube/peertube-models'
async function doesVideoBlacklistExist (videoId: number, res: Response) {
const videoBlacklist = await VideoBlacklistModel.loadByVideoId(videoId)
if (videoBlacklist === null) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Blacklisted video not found'
})
return false
}
res.locals.videoBlacklist = videoBlacklist
return true
}
// ---------------------------------------------------------------------------
export {
doesVideoBlacklistExist
}

View File

@@ -0,0 +1,25 @@
import { Response } from 'express'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { MVideoId } from '@server/types/models/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
async function doesVideoCaptionExist (video: MVideoId, language: string, res: Response) {
const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
if (!videoCaption) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video caption not found'
})
return false
}
res.locals.videoCaption = videoCaption
return true
}
// ---------------------------------------------------------------------------
export {
doesVideoCaptionExist
}

View File

@@ -0,0 +1,24 @@
import express from 'express'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import { HttpStatusCode } from '@peertube/peertube-models'
async function doesVideoChannelSyncIdExist (id: number, res: express.Response) {
const sync = await VideoChannelSyncModel.loadWithChannel(+id)
if (!sync) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video channel sync not found'
})
return false
}
res.locals.videoChannelSync = sync
return true
}
// ---------------------------------------------------------------------------
export {
doesVideoChannelSyncIdExist
}

View File

@@ -0,0 +1,124 @@
import { HttpStatusCode, UserRight, UserRightType } from '@peertube/peertube-models'
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MChannelBannerAccountDefault, MChannelUserId, MUserAccountId } from '@server/types/models/index.js'
import express from 'express'
import { checkCanManageAccount } from './users.js'
type CommonOptions = {
checkCanManage: boolean // Also check the user can manage the account
checkIsOwner: boolean // Also check this is the owner of the channel
req: express.Request
res: express.Response
specialRight?: UserRightType
}
export async function doesChannelIdExist (
options: CommonOptions & {
id: number
checkIsLocal: boolean // Also check this is a local channel
}
) {
const { id, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight } = options
const channel = await VideoChannelModel.loadAndPopulateAccount(+id)
return processVideoChannelExist({ channel, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight })
}
export async function doesChannelHandleExist (
options: CommonOptions & {
handle: string
checkIsLocal: boolean // Also check this is a local channel
}
) {
const { handle, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight } = options
const channel = await VideoChannelModel.loadByHandleAndPopulateAccount(handle)
return processVideoChannelExist({ channel, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight })
}
export async function checkCanManageChannel (
options: CommonOptions & {
user: MUserAccountId
channel: MChannelUserId
}
) {
const { channel, user, req, res, checkCanManage, checkIsOwner, specialRight = UserRight.MANAGE_ANY_VIDEO_CHANNEL } = options
if (!channel) {
res?.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video channel not found')
})
return false
}
if (checkIsOwner || checkCanManage) {
if (!user) {
res?.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('Authentication is required')
})
return false
}
const isOwner = checkCanManageAccount({
account: channel.Account,
user,
req,
res: null,
specialRight
})
if (!isOwner) {
if (checkIsOwner) {
res?.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('This user has not owner rights on this channel')
})
return false
}
if (checkCanManage && !await VideoChannelCollaboratorModel.isCollaborator({ user, channel })) {
res?.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('This user cannot manage this channel')
})
return false
}
}
}
return true
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function processVideoChannelExist (
options: CommonOptions & {
channel: MChannelBannerAccountDefault
checkIsLocal: boolean // Also check this is a local channel
}
) {
const { channel, req, res, checkCanManage, checkIsLocal, checkIsOwner, specialRight = UserRight.MANAGE_ANY_VIDEO_CHANNEL } = options
const user = res.locals.oauth?.token.User
if (!await checkCanManageChannel({ channel, user, req, res, checkCanManage, checkIsOwner, specialRight })) return false
if (checkIsLocal && channel.Actor.isLocal() === false) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('The channel must be local.')
})
return false
}
res.locals.videoChannel = channel
return true
}

View File

@@ -0,0 +1,80 @@
import express from 'express'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import { MVideoId } from '@server/types/models/index.js'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ServerErrorCode } from '@peertube/peertube-models'
async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
const id = forceNumber(idArg)
const videoComment = await VideoCommentModel.loadById(id)
if (!videoComment) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video comment thread not found'
})
return false
}
if (videoComment.videoId !== video.id) {
res.fail({
type: ServerErrorCode.COMMENT_NOT_ASSOCIATED_TO_VIDEO,
message: 'Video comment is not associated to this video.'
})
return false
}
if (videoComment.inReplyToCommentId !== null) {
res.fail({ message: 'Video comment is not a thread.' })
return false
}
res.locals.videoCommentThread = videoComment
return true
}
async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
const id = forceNumber(idArg)
const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
if (!videoComment) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video comment thread not found'
})
return false
}
if (videoComment.videoId !== video.id) {
res.fail({
type: ServerErrorCode.COMMENT_NOT_ASSOCIATED_TO_VIDEO,
message: 'Video comment is not associated to this video.'
})
return false
}
res.locals.videoCommentFull = videoComment
return true
}
async function doesCommentIdExist (idArg: number | string, res: express.Response) {
const id = forceNumber(idArg)
const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
if (!videoComment) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video comment thread not found'
})
return false
}
res.locals.videoCommentFull = videoComment
return true
}
export {
doesVideoCommentThreadExist,
doesVideoCommentExist,
doesCommentIdExist
}

View File

@@ -0,0 +1,22 @@
import express from 'express'
import { VideoImportModel } from '@server/models/video/video-import.js'
import { HttpStatusCode } from '@peertube/peertube-models'
async function doesVideoImportExist (id: number, res: express.Response) {
const videoImport = await VideoImportModel.loadAndPopulateVideo(id)
if (!videoImport) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video import not found'
})
return false
}
res.locals.videoImport = videoImport
return true
}
export {
doesVideoImportExist
}

View File

@@ -0,0 +1,21 @@
import express from 'express'
import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership.js'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
export async function doesChangeVideoOwnershipExist (idArg: number | string, req: express.Request, res: express.Response) {
const id = forceNumber(idArg)
const videoChangeOwnership = await VideoChangeOwnershipModel.load(id)
if (!videoChangeOwnership) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video ownership change not found')
})
return false
}
res.locals.videoChangeOwnership = videoChangeOwnership
return true
}

View File

@@ -0,0 +1,72 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight, VideoPrivacy } from '@peertube/peertube-models'
import { getVideoWithAttributes } from '@server/helpers/video.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { MUserAccountId, MVideoAccountLight } from '@server/types/models/index.js'
import express from 'express'
import { header } from 'express-validator'
import { checkCanManageVideo } from './videos.js'
export function isValidVideoPasswordHeader () {
return header('x-peertube-video-password')
.optional()
.isString()
}
export function checkVideoIsPasswordProtected (req: express.Request, res: express.Response) {
const video = getVideoWithAttributes(res)
if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Video is not password protected')
})
return false
}
return true
}
export async function doesVideoPasswordExist (options: {
id: number | string
req: express.Request
res: express.Response
}) {
const { req, res } = options
const video = getVideoWithAttributes(res)
const id = forceNumber(options.id)
const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
if (!videoPassword) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video password not found')
})
return false
}
res.locals.videoPassword = videoPassword
return true
}
export async function checkCanDeleteVideoPassword (options: {
user: MUserAccountId
video: MVideoAccountLight
req: express.Request
res: express.Response
}) {
const { user, video, req, res } = options
const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
if (passwordCount <= 1) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot delete the last password of the protected video')
})
return false
}
return checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })
}

View File

@@ -0,0 +1,42 @@
import express from 'express'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylist } from '@server/types/models/index.js'
import { HttpStatusCode } from '@peertube/peertube-models'
export type VideoPlaylistFetchType = 'summary' | 'all'
export async function doesVideoPlaylistExist (options: {
id: number | string
req: express.Request
res: express.Response
fetchType?: VideoPlaylistFetchType
}) {
const { id, req, res, fetchType = 'summary' } = options
if (fetchType === 'summary') {
const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannelSummary(id, undefined)
res.locals.videoPlaylistSummary = videoPlaylist
return handleVideoPlaylist(videoPlaylist, req, res)
}
const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(id, undefined)
res.locals.videoPlaylistFull = videoPlaylist
return handleVideoPlaylist(videoPlaylist, req, res)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function handleVideoPlaylist (playlist: MVideoPlaylist, req: express.Request, res: express.Response) {
if (!playlist) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video playlist not found')
})
return false
}
return true
}

View File

@@ -0,0 +1,364 @@
import { HttpStatusCode, ServerErrorCode, UserRight, UserRightType, VideoPrivacy } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
import { VideoLoadType, loadVideo } from '@server/lib/model-loaders/index.js'
import { isUserQuotaValid } from '@server/lib/user.js'
import { VideoTokensManager } from '@server/lib/video-tokens-manager.js'
import { authenticateOrFail } from '@server/middlewares/auth.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoModel } from '@server/models/video/video.js'
import {
MUserAccountId,
MUserAccountUrl,
MUserId,
MVideo,
MVideoAccountLight,
MVideoFormattableDetails,
MVideoFullLight,
MVideoId,
MVideoImmutable,
MVideoThumbnailBlacklist,
MVideoUUID,
MVideoWithRights
} from '@server/types/models/index.js'
import { Request, Response } from 'express'
import { checkCanManageChannel } from './video-channels.js'
export async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
const video = await loadVideo(id, fetchType, userId)
if (!video) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video not found'
})
return false
}
switch (fetchType) {
case 'for-api':
res.locals.videoAPI = video as MVideoFormattableDetails
break
case 'all':
res.locals.videoAll = video as MVideoFullLight
break
case 'unsafe-only-immutable-attributes':
res.locals.onlyImmutableVideo = video as MVideoImmutable
break
case 'id':
res.locals.videoId = video as MVideoId
break
case 'only-video-and-blacklist':
res.locals.onlyVideo = video as MVideoThumbnailBlacklist
break
}
return true
}
// ---------------------------------------------------------------------------
export async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) {
if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) {
res.sendStatus(HttpStatusCode.NOT_FOUND_404)
return false
}
return true
}
// ---------------------------------------------------------------------------
export async function checkCanSeeVideo (options: {
req: Request
res: Response
paramId: string
video: MVideo
videoFileToken?: {
user: MUserAccountUrl
}
}) {
const { req, res, video, videoFileToken, paramId } = options
if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
return checkCanSeeUserAuthVideo({ req, res, video, videoFileTokenUser: videoFileToken?.user })
}
if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
return checkCanSeePasswordProtectedVideo({ req, res, video, hasVideoFileToken: !!videoFileToken })
}
if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
return true
}
throw new Error('Unknown video privacy when checking video right ' + video.url)
}
async function checkCanSeeUserAuthVideo (options: {
req: Request
res: Response
video: MVideoId | MVideoWithRights
videoFileTokenUser?: MUserAccountUrl
}) {
const { req, res, video } = options
const fail = () => {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot fetch information of private/internal/blocked video')
})
return false
}
let user = options.videoFileTokenUser
if (!user) {
if (!await authenticateOrFail({ req, res })) return false
user = res.locals.oauth.token.User
}
const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
const privacy = videoWithRights.privacy
if (privacy === VideoPrivacy.INTERNAL) {
// We know we have a user
return true
}
if (videoWithRights.isBlacklisted()) {
if (await canUserManageProtectedVideo({ user, req, video: videoWithRights, right: UserRight.MANAGE_VIDEO_BLACKLIST })) return true
return fail()
}
if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
if (await canUserManageProtectedVideo({ user, req, video: videoWithRights, right: UserRight.SEE_ALL_VIDEOS })) return true
return fail()
}
// Should not happen
return fail()
}
async function checkCanSeePasswordProtectedVideo (options: {
req: Request
res: Response
video: MVideo
hasVideoFileToken: boolean
}) {
const { req, res, video, hasVideoFileToken } = options
const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
const videoPassword = req.header('x-peertube-video-password')
if (!exists(videoPassword)) {
if (hasVideoFileToken === true) return true
const errorMessage = req.t('Please provide a password to access this password protected video')
const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
if (!await authenticateOrFail({ req, res, errorMessage, errorStatus: HttpStatusCode.UNAUTHORIZED_401, errorType })) return false
const user = res.locals.oauth.token.User
if (await canUserManageProtectedVideo({ user, req, video: videoWithRights, right: UserRight.SEE_ALL_VIDEOS })) return true
res.fail({
status: user
? HttpStatusCode.FORBIDDEN_403
: HttpStatusCode.UNAUTHORIZED_401,
type: errorType,
message: errorMessage
})
return false
}
if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
message: req.t('Incorrect video password. Access to the video is denied')
})
return false
}
async function canUserManageProtectedVideo (options: {
req: Request
user: MUserAccountId
video: MVideoWithRights | MVideoAccountLight
right: UserRightType
}) {
const { user, video, right, req } = options
return checkCanManageChannel({
channel: video.VideoChannel,
user,
req,
res: null,
checkCanManage: true,
checkIsOwner: false,
specialRight: right
})
}
async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
const channel = video.VideoChannel
if (channel?.id && channel?.Account?.userId && channel?.Account?.id) return video
return VideoModel.loadFull(video.id)
}
// ---------------------------------------------------------------------------
export async function checkCanAccessVideoStaticFiles (options: {
video: MVideo
req: Request
res: Response
paramId: string
}): Promise<boolean> {
const { video, req, res } = options
if (!checkVideoTokenIfNeeded(req, res, video)) return false
return checkCanSeeVideo({ ...options, videoFileToken: res.locals.videoFileToken })
}
export async function checkCanAccessVideoSourceFile (options: {
videoId: number
req: Request
res: Response
}): Promise<boolean> {
const { req, res, videoId } = options
const video = await VideoModel.loadFull(videoId)
let user = res.locals.oauth?.token.User
if (!user) {
if (!checkVideoTokenIfNeeded(req, res, video)) return false
user = res.locals.videoFileToken?.user
}
if (!user) {
res.fail({ status: HttpStatusCode.UNAUTHORIZED_401, message: req.t('Authentication is required to access the video source file') })
return false
}
if (await canUserManageProtectedVideo({ user, req, video, right: UserRight.SEE_ALL_VIDEOS }) === true) {
return true
}
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return false
}
function checkVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUID) {
const videoFileToken = req.query.videoFileToken
if (videoFileToken) {
if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
res.locals.videoFileToken = { user }
} else {
res.fail({
message: req.t('Invalid video file token'),
status: HttpStatusCode.FORBIDDEN_403
})
return false
}
}
return true
}
// ---------------------------------------------------------------------------
export async function checkCanManageVideo (options: {
user: MUserAccountId
video: MVideoAccountLight
right: UserRightType
req: Request
res: Response | null
checkIsLocal: boolean
checkIsOwner: boolean
}) {
const { user, video, right, req, res, checkIsLocal, checkIsOwner } = options
if (!user) {
res?.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('Authentication is required.')
})
return false
}
if (checkIsLocal && video.isLocal() === false) {
res?.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage a video of another server.')
})
return false
}
if (
!await checkCanManageChannel({
channel: video.VideoChannel,
user,
req,
res: null,
checkCanManage: true,
checkIsOwner,
specialRight: right
})
) {
res?.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage a video of another user.')
})
return false
}
return true
}
type NewType = MUserId
// ---------------------------------------------------------------------------
export async function checkUserQuota (options: {
user: NewType
videoFileSize: number
req: Request
res: Response
}) {
const { user, videoFileSize, req, res } = options
if (await isUserQuotaValid({ userId: user.id, uploadSize: videoFileSize }) === false) {
res.fail({
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
message: req.t('The user video quota is exceeded with this video'),
type: ServerErrorCode.QUOTA_REACHED
})
return false
}
return true
}

View File

@@ -0,0 +1,73 @@
import express from 'express'
import { query } from 'express-validator'
import { SORTABLE_COLUMNS } from '../../initializers/constants.js'
import { areValidationErrors } from './shared/index.js'
export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
export const watchedWordsListsSortValidator = checkSortFactory(SORTABLE_COLUMNS.WATCHED_WORDS_LISTS)
export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
export const videoChannelFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
export const videoChannelActivitiesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_ACTIVITIES)
export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
export const tokenSessionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.TOKEN_SESSIONS)
export const runnersSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNERS)
export const runnerRegistrationTokensSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_REGISTRATION_TOKENS)
export const runnerJobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_JOBS)
export const liveSessionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.LIVE_SESSIONS)
// ---------------------------------------------------------------------------
function checkSortFactory (columns: string[], tags: string[] = []) {
return checkSort(createSortableColumns(columns), tags)
}
function checkSort (sortableColumns: string[], tags: string[] = []) {
return [
query('sort')
.optional()
.isIn(sortableColumns),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
return next()
}
]
}
function createSortableColumns (sortableColumns: string[]) {
const sortableColumnDesc = sortableColumns.map(sortableColumn => '-' + sortableColumn)
return sortableColumns.concat(sortableColumnDesc)
}

View File

@@ -0,0 +1,204 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import {
exists,
isSafeFilename,
isSafePeerTubeFilenameWithoutExtension,
isUUIDValid,
toBooleanOrNull
} from '@server/helpers/custom-validators/misc.js'
import { logger } from '@server/helpers/logger.js'
import { LRU_CACHE } from '@server/initializers/constants.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStreamingPlaylist, MVideoFile, MVideoThumbnailBlacklist } from '@server/types/models/index.js'
import express from 'express'
import { param, query } from 'express-validator'
import { LRUCache } from 'lru-cache'
import { basename } from 'path'
import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared/index.js'
type LRUValue = {
allowed: boolean
video?: MVideoThumbnailBlacklist
file?: MVideoFile
playlist?: MStreamingPlaylist
}
const staticFileTokenBypass = new LRUCache<string, LRUValue>({
max: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.MAX_SIZE,
ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL
})
export const ensureCanAccessVideoPrivateWebVideoFiles = [
query('videoFileToken').optional().custom(exists),
isValidVideoPasswordHeader(),
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 { allowed, file, video } = staticFileTokenBypass.get(cacheKey)
if (allowed === true) {
res.locals.onlyVideo = video
res.locals.videoFile = file
return next()
}
return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
}
const result = await isWebVideoAllowed(req, res)
staticFileTokenBypass.set(cacheKey, result)
if (result.allowed !== true) return
res.locals.onlyVideo = result.video
res.locals.videoFile = result.file
return next()
}
]
export const privateM3U8PlaylistValidator = [
param('videoUUID')
.custom(isUUIDValid),
param('playlistNameWithoutExtension')
.custom(v => isSafePeerTubeFilenameWithoutExtension(v)),
query('reinjectVideoFileToken')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should be a valid reinjectVideoFileToken boolean'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const privateHLSFileValidator = [
param('videoUUID')
.custom(isUUIDValid),
param('filename')
.custom(v => isSafeFilename(v)),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const ensureCanAccessPrivateVideoHLSFiles = [
query('videoFileToken')
.optional()
.custom(exists),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const videoUUID = req.params.videoUUID
const token = extractTokenOrDie(req, res)
if (!token) return
const cacheKey = token + '-' + videoUUID
if (staticFileTokenBypass.has(cacheKey)) {
const { allowed, file, playlist, video } = staticFileTokenBypass.get(cacheKey)
if (allowed === true) {
res.locals.onlyVideo = video
res.locals.videoFile = file
res.locals.videoStreamingPlaylist = playlist
return next()
}
return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
}
const result = await isHLSAllowed(req, res, videoUUID)
staticFileTokenBypass.set(cacheKey, result)
if (result.allowed !== true) return
res.locals.onlyVideo = result.video
res.locals.videoFile = result.file
res.locals.videoStreamingPlaylist = result.playlist
return next()
}
]
// ---------------------------------------------------------------------------
async function isWebVideoAllowed (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 { allowed: false }
}
const video = await VideoModel.loadWithBlacklist(file.getVideo().id)
return {
file,
video,
allowed: await checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid })
}
}
async function isHLSAllowed (req: express.Request, res: express.Response, videoUUID: string) {
const filename = basename(req.path)
const video = await VideoModel.loadAndPopulateAccountAndFiles(videoUUID)
if (!video) {
logger.debug('Unknown static file %s to serve', req.originalUrl, { videoUUID })
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return { allowed: false }
}
const file = await VideoFileModel.loadByFilename(filename)
return {
file,
video,
playlist: video.getHLSPlaylist(),
allowed: await checkCanAccessVideoStaticFiles({ req, res, video, paramId: video.uuid })
}
}
function extractTokenOrDie (req: express.Request, res: express.Response) {
const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
if (!token) {
return res.fail({
message: 'Video password header, video file token query parameter and bearer token are all missing', //
status: HttpStatusCode.UNAUTHORIZED_401
})
}
return token
}

View File

@@ -0,0 +1,40 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { param } from 'express-validator'
import { isSafePath, isStableOrUnstableVersionValid } from '../../helpers/custom-validators/misc.js'
import { isPluginNameValid } from '../../helpers/custom-validators/plugins.js'
import { PluginManager } from '../../lib/plugins/plugin-manager.js'
import { areValidationErrors } from './shared/index.js'
export const serveThemeCSSValidator = [
param('themeName')
.custom(isPluginNameValid),
param('themeVersion')
.custom(isStableOrUnstableVersionValid),
param('staticEndpoint')
.custom(isSafePath),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const theme = PluginManager.Instance.getRegisteredThemeByShortName(req.params.themeName)
if (!theme || theme.version !== req.params.themeVersion) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No theme named ' + req.params.themeName + ' was found with version ' + req.params.themeVersion
})
}
if (theme.css.includes(req.params.staticEndpoint) === false) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No static endpoint was found for this theme'
})
}
res.locals.registeredPlugin = theme
return next()
}
]

View File

@@ -0,0 +1,46 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
import express from 'express'
import { param } from 'express-validator'
import { checkCanManageAccount, checkUserIdExist } from './shared/users.js'
import { areValidationErrors } from './shared/utils.js'
export const manageTokenSessionsValidator = [
param('userId').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.userId, res)) return
const authUser = res.locals.oauth.token.User
const targetUser = res.locals.user
if (!checkCanManageAccount({ account: targetUser.Account, user: authUser, req, res, specialRight: UserRight.MANAGE_USERS })) return
return next()
}
]
export const revokeTokenSessionValidator = [
param('tokenSessionId').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const targetUser = res.locals.user
const session = await OAuthTokenModel.loadSessionOf({ id: +req.params.tokenSessionId, userId: targetUser.id })
if (!session) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('The token session does not exist or does not belong to the user.')
})
}
res.locals.tokenSession = session
return next()
}
]

View File

@@ -0,0 +1,81 @@
import express from 'express'
import { body, param } from 'express-validator'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { exists, isIdValid } from '../../helpers/custom-validators/misc.js'
import { areValidationErrors, checkUserIdExist } from './shared/index.js'
const requestOrConfirmTwoFactorValidator = [
param('id').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
if (res.locals.user.otpSecret) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Two factor is already enabled.`
})
}
return next()
}
]
const confirmTwoFactorValidator = [
body('requestToken').custom(exists),
body('otpToken').custom(exists),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const disableTwoFactorValidator = [
param('id').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
if (!res.locals.user.otpSecret) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Two factor is already disabled.`
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
requestOrConfirmTwoFactorValidator,
confirmTwoFactorValidator,
disableTwoFactorValidator
}
// ---------------------------------------------------------------------------
async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) {
const authUser = res.locals.oauth.token.user
if (!await checkUserIdExist(userId, res)) return
if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: `User ${authUser.username} does not have right to change two factor setting of this user.`
})
return false
}
return true
}

View File

@@ -0,0 +1,7 @@
export * from './user-email-verification.js'
export * from './user-exports.js'
export * from './user-history.js'
export * from './user-notifications.js'
export * from './user-registrations.js'
export * from './user-subscriptions.js'
export * from './users.js'

View File

@@ -0,0 +1 @@
export * from './user-registrations.js'

View File

@@ -0,0 +1,64 @@
import express from 'express'
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import { MRegistration } from '@server/types/models/index.js'
import { forceNumber, pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getByEmailPermissive } from '@server/lib/user.js'
export function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
const id = forceNumber(idArg)
return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
}
export function checkRegistrationEmailExistPermissive (email: string, res: express.Response, abortResponse = true) {
return checkRegistrationExist(
async () => {
const registrations = await UserRegistrationModel.listByEmailCaseInsensitive(email)
return getByEmailPermissive(registrations, email)
},
res,
abortResponse
)
}
export async function checkRegistrationHandlesDoNotAlreadyExist (options: {
username: string
channelHandle: string
email: string
res: express.Response
}) {
const { res } = options
const registrations = await UserRegistrationModel.listByEmailCaseInsensitiveOrHandle(
pick(options, [ 'username', 'email', 'channelHandle' ])
)
if (registrations.length !== 0) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Registration with this username, channel name or email already exists.'
})
return false
}
return true
}
export async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
const registration = await finder()
if (!registration) {
if (abortResponse === true) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'User not found'
})
}
return false
}
res.locals.userRegistration = registration
return true
}

View File

@@ -0,0 +1,128 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getByEmailPermissive } from '@server/lib/user.js'
import { UserModel } from '@server/models/user/user.js'
import express from 'express'
import { body, param } from 'express-validator'
import { logger } from '../../../helpers/logger.js'
import { Redis } from '../../../lib/redis.js'
import { areValidationErrors, checkUserIdExist } from '../shared/index.js'
import { checkRegistrationEmailExistPermissive, checkRegistrationIdExist } from './shared/user-registrations.js'
export const usersAskSendUserVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { email } = await Hooks.wrapObject({
email: req.body.email
}, 'filter:api.email-verification.ask-send-verify-email.body')
const [ userEmail, userPendingEmail ] = await Promise.all([
UserModel.loadByEmailCaseInsensitive(email).then(users => getByEmailPermissive(users, email)),
UserModel.loadByPendingEmailCaseInsensitive(email).then(users => getByEmailPermissive(users, email))
])
if (userEmail && userPendingEmail) {
logger.error(`Found 2 users with email ${email} to send verification link.`)
// Do not leak our emails
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
if (!userEmail && !userPendingEmail) {
logger.debug(`User with email ${email} does not exist (asking verify email).`)
// Do not leak our emails
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
res.locals.userEmail = userEmail
res.locals.userPendingEmail = userPendingEmail
const user = userEmail || userPendingEmail
if (user.pluginAuth) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Cannot ask verification email of a user that uses a plugin authentication.'
})
}
return next()
}
]
export const usersAskSendRegistrationVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { email } = await Hooks.wrapObject({
email: req.body.email
}, 'filter:api.email-verification.ask-send-verify-email.body')
const registrationExists = await checkRegistrationEmailExistPermissive(email, res, false)
if (!registrationExists) {
logger.debug(`Registration with email ${email} does not exist (asking verify email).`)
// Do not leak our emails
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
return next()
}
]
export const usersVerifyEmailValidator = [
param('id')
.isInt().not().isEmpty().withMessage('Should have a valid id'),
body('verificationString')
.not().isEmpty().withMessage('Should have a valid verification string'),
body('isPendingEmail')
.optional()
.customSanitizer(toBooleanOrNull),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
if (redisVerificationString !== req.body.verificationString) {
return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
}
return next()
}
]
// ---------------------------------------------------------------------------
export const registrationVerifyEmailValidator = [
param('registrationId')
.isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
body('verificationString')
.not().isEmpty().withMessage('Should have a valid verification string'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
const registration = res.locals.userRegistration
const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
if (redisVerificationString !== req.body.verificationString) {
return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
}
return next()
}
]

View File

@@ -0,0 +1,155 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { HttpStatusCode, ServerErrorCode, UserExportRequest, UserExportState, UserRight } from '@peertube/peertube-models'
import { areValidationErrors, checkUserIdExist } from '../shared/index.js'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { UserExportModel } from '@server/models/user/user-export.js'
import { MUserExport } from '@server/types/models/index.js'
import { CONFIG } from '@server/initializers/config.js'
import { getOriginalVideoFileTotalFromUser } from '@server/lib/user.js'
export const userExportsListValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureExportIsEnabled(res)) return
if (!await checkUserIdRight(req.params.userId, res)) return
return next()
}
]
export const userExportRequestValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
body('withVideoFiles')
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have withVideoFiles boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureExportIsEnabled(res)) return
if (!await checkUserIdRight(req.params.userId, res)) return
// Check not already created
const exportsList = await UserExportModel.listByUser(res.locals.user)
if (exportsList.filter(e => e.state !== UserExportState.ERRORED).length !== 0) {
return res.fail({
message: 'User has already processing or completed exports'
})
}
const body: UserExportRequest = req.body
if (body.withVideoFiles) {
const quota = await getOriginalVideoFileTotalFromUser(res.locals.user)
if (quota > CONFIG.EXPORT.USERS.MAX_USER_VIDEO_QUOTA) {
return res.fail({
message: 'User video quota exceeds the maximum limit set by the admin to create a user archive containing videos',
type: ServerErrorCode.MAX_USER_VIDEO_QUOTA_EXCEEDED_FOR_USER_EXPORT,
status: HttpStatusCode.FORBIDDEN_403
})
}
}
return next()
}
]
export const userExportDeleteValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
param('id')
.isInt().not().isEmpty().withMessage('Should have a valid id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureExportIsEnabled(res)) return
if (!await checkUserIdRight(req.params.userId, res)) return
const userExport = await UserExportModel.load(req.params.id + '')
if (!userExport) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (!checkUserExportRight(userExport, res)) return
if (!userExport.canBeSafelyRemoved()) {
return res.fail({
message: 'Cannot delete this user export because its state is not compatible with a deletion'
})
}
res.locals.userExport = userExport
return next()
}
]
export const userExportDownloadValidator = [
param('filename').exists(),
query('jwt').isJWT(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!ensureExportIsEnabled(res)) return
const userExport = await UserExportModel.loadByFilename(req.params.filename)
if (!userExport) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (userExport.isJWTValid(req.query.jwt) !== true) return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
res.locals.userExport = userExport
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkUserIdRight (userId: number | string, res: express.Response) {
if (!await checkUserIdExist(userId, res)) return false
const oauthUser = res.locals.oauth.token.User
if (!oauthUser.hasRight(UserRight.MANAGE_USER_EXPORTS) && oauthUser.id !== res.locals.user.id) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot manage exports of another user'
})
return false
}
return true
}
function checkUserExportRight (userExport: MUserExport, res: express.Response) {
if (userExport.userId !== res.locals.user.id) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Export is not associated to this user'
})
return false
}
return true
}
function ensureExportIsEnabled (res: express.Response) {
if (CONFIG.EXPORT.USERS.ENABLED !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'User export is disabled on this instance'
})
return false
}
return true
}

View File

@@ -0,0 +1,47 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { exists, isDateValid, isIdValid } from '../../../helpers/custom-validators/misc.js'
import { areValidationErrors } from '../shared/index.js'
const userHistoryListValidator = [
query('search')
.optional()
.custom(exists),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const userHistoryRemoveAllValidator = [
body('beforeDate')
.optional()
.custom(isDateValid).withMessage('Should have a before date that conforms to ISO 8601'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const userHistoryRemoveElementValidator = [
param('videoId')
.custom(isIdValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
userHistoryListValidator,
userHistoryRemoveElementValidator,
userHistoryRemoveAllValidator
}

View File

@@ -0,0 +1,110 @@
import express from 'express'
import { param } from 'express-validator'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { Metadata as UploadXMetadata } from '@uploadx/core'
import { areValidationErrors, checkUserIdExist } from '../shared/index.js'
import { CONFIG } from '@server/initializers/config.js'
import { HttpStatusCode, ServerErrorCode, UserImportState, UserRight } from '@peertube/peertube-models'
import { isUserQuotaValid } from '@server/lib/user.js'
import { UserImportModel } from '@server/models/user/user-import.js'
export const userImportRequestResumableValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const file = buildUploadXFile(req.body as express.CustomUploadXFile<UploadXMetadata>)
const cleanup = () => safeUploadXCleanup(file)
if (!await checkUserIdRight(req.params.userId, res)) return cleanup()
if (CONFIG.IMPORT.USERS.ENABLED !== true) {
res.fail({
message: 'User import is not enabled by the administrator',
status: HttpStatusCode.BAD_REQUEST_400
})
return cleanup()
}
res.locals.importUserFileResumable = { ...file, originalname: file.filename }
return next()
}
]
export const userImportRequestResumableInitValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.IMPORT.USERS.ENABLED !== true) {
return res.fail({
message: 'User import is not enabled by the administrator',
status: HttpStatusCode.BAD_REQUEST_400
})
}
if (req.body.filename.endsWith('.zip') !== true) {
return res.fail({
message: 'User import file must be a zip',
status: HttpStatusCode.BAD_REQUEST_400
})
}
if (!await checkUserIdRight(req.params.userId, res)) return
const fileMetadata = res.locals.uploadVideoFileResumableMetadata
const user = res.locals.user
if (await isUserQuotaValid({ userId: user.id, uploadSize: fileMetadata.size }) === false) {
return res.fail({
message: 'User video quota is exceeded with this import',
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
type: ServerErrorCode.QUOTA_REACHED
})
}
const userImport = await UserImportModel.loadLatestByUserId(user.id)
if (userImport && userImport.state !== UserImportState.ERRORED && userImport.state !== UserImportState.COMPLETED) {
return res.fail({
message: 'An import is already being processed',
status: HttpStatusCode.BAD_REQUEST_400
})
}
return next()
}
]
export const getLatestImportStatusValidator = [
param('userId')
.isInt().not().isEmpty().withMessage('Should have a valid userId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!await checkUserIdRight(req.params.userId, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkUserIdRight (userId: number | string, res: express.Response) {
if (!await checkUserIdExist(userId, res)) return false
const oauthUser = res.locals.oauth.token.User
if (!oauthUser.hasRight(UserRight.MANAGE_USER_IMPORTS) && oauthUser.id !== res.locals.user.id) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot manage imports of another user'
})
return false
}
return true
}

View File

@@ -0,0 +1,70 @@
import { arrayify } from '@peertube/peertube-core-utils'
import { isNumberArray } from '@server/helpers/custom-validators/search.js'
import express from 'express'
import { body, query } from 'express-validator'
import { isNotEmptyIntArray, toBooleanOrNull } from '../../../helpers/custom-validators/misc.js'
import { isUserNotificationSettingValid } from '../../../helpers/custom-validators/user-notifications.js'
import { areValidationErrors } from '../shared/index.js'
export const listUserNotificationsValidator = [
query('unread')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should have a valid unread boolean'),
query('typeOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid typeOneOf array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const updateNotificationSettingsValidator = [
body('newVideoFromSubscription')
.custom(isUserNotificationSettingValid),
body('newCommentOnMyVideo')
.custom(isUserNotificationSettingValid),
body('abuseAsModerator')
.custom(isUserNotificationSettingValid),
body('videoAutoBlacklistAsModerator')
.custom(isUserNotificationSettingValid),
body('blacklistOnMyVideo')
.custom(isUserNotificationSettingValid),
body('myVideoImportFinished')
.custom(isUserNotificationSettingValid),
body('myVideoPublished')
.custom(isUserNotificationSettingValid),
body('commentMention')
.custom(isUserNotificationSettingValid),
body('newFollow')
.custom(isUserNotificationSettingValid),
body('newUserRegistration')
.custom(isUserNotificationSettingValid),
body('newInstanceFollower')
.custom(isUserNotificationSettingValid),
body('autoInstanceFollowing')
.custom(isUserNotificationSettingValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const markAsReadUserNotificationsValidator = [
body('ids')
.optional()
.custom(isNotEmptyIntArray).withMessage('Should have a valid array of notification ids'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]

View File

@@ -0,0 +1,206 @@
import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@peertube/peertube-models'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration.js'
import { CONFIG } from '@server/initializers/config.js'
import { loadReservedActorName } from '@server/lib/local-actor.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../../helpers/custom-validators/users.js'
import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../../helpers/custom-validators/video-channels.js'
import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../../lib/signup.js'
import { areValidationErrors, checkUsernameOrEmailDoNotAlreadyExist } from '../shared/index.js'
import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations.js'
const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
const usersRequestRegistrationValidator = [
...usersCommonRegistrationValidatorFactory([
body('registrationReason')
.custom(isRegistrationReasonValid)
]),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const body: UserRegistrationRequest = req.body
if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Signup approval is not enabled on this instance')
})
}
const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
return next()
}
]
// ---------------------------------------------------------------------------
function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const allowedParams = {
body: req.body,
ip: req.ip,
signupMode
}
const allowedResult = await Hooks.wrapPromiseFun(
isSignupAllowed,
allowedParams,
signupMode === 'direct-registration'
? 'filter:api.user.signup.allowed.result'
: 'filter:api.user.request-signup.allowed.result'
)
if (allowedResult.allowed === false) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: allowedResult.errorMessage || req.t('User registration is not allowed')
})
}
return next()
}
}
const ensureUserRegistrationAllowedForIP = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const allowed = isSignupAllowedForCurrentIP(req.ip)
if (allowed === false) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('You are not on a network authorized for registration.')
})
}
return next()
}
]
// ---------------------------------------------------------------------------
const acceptOrRejectRegistrationValidator = [
param('registrationId')
.custom(isIdValid),
body('moderationResponse')
.custom(isRegistrationModerationResponseValid),
body('preventEmailDelivery')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have preventEmailDelivery boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('This registration is already accepted or rejected.')
})
}
return next()
}
]
// ---------------------------------------------------------------------------
const getRegistrationValidator = [
param('registrationId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
const listRegistrationsValidator = [
query('search')
.optional()
.custom(exists),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
acceptOrRejectRegistrationValidator,
ensureUserRegistrationAllowedFactory,
ensureUserRegistrationAllowedForIP,
getRegistrationValidator,
listRegistrationsValidator,
usersDirectRegistrationValidator,
usersRequestRegistrationValidator
}
// ---------------------------------------------------------------------------
function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
return [
body('username')
.custom(isUserUsernameValid),
body('password')
.custom(isUserPasswordValid),
body('email')
.isEmail(),
body('displayName')
.optional()
.custom(isUserDisplayNameValid),
body('channel.name')
.optional()
.custom(isVideoChannelUsernameValid),
body('channel.displayName')
.optional()
.custom(isVideoChannelDisplayNameValid),
...additionalValidationChain,
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { omitBodyLog: true })) return
const body: UserRegister | UserRegistrationRequest = req.body
if (!await checkUsernameOrEmailDoNotAlreadyExist({ username: body.username, email: body.email, req, res })) return
if (body.channel) {
if (!body.channel.name || !body.channel.displayName) {
return res.fail({
message: req.t('Channel is optional but if you specify it, channel.name and channel.displayName are required.')
})
}
if (body.channel.name === body.username) {
return res.fail({ message: req.t('Channel name cannot be the same as user username.') })
}
const existing = await loadReservedActorName(body.channel.name)
if (existing) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t(`Channel with name {name} already exists.`, { name: body.channel.name })
})
}
}
return next()
}
]
}

View File

@@ -0,0 +1,110 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { arrayify } from '@peertube/peertube-core-utils'
import { FollowState, HttpStatusCode } from '@peertube/peertube-models'
import { areValidActorHandles, isValidActorHandle } from '../../../helpers/custom-validators/activitypub/actor.js'
import { WEBSERVER } from '../../../initializers/constants.js'
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
import { areValidationErrors } from '../shared/index.js'
const userSubscriptionListValidator = [
query('search')
.optional()
.not().isEmpty(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const userSubscriptionAddValidator = [
body('uri')
.custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const areSubscriptionsExistValidator = [
query('uris')
.customSanitizer(arrayify)
.custom(areValidActorHandles).withMessage('Should have a valid array of URIs'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
const userSubscriptionGetValidator = [
param('uri')
.custom(isValidActorHandle),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesSubscriptionExist({ uri: req.params.uri, res, state: 'accepted' })) return
return next()
}
]
const userSubscriptionDeleteValidator = [
param('uri')
.custom(isValidActorHandle),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesSubscriptionExist({ uri: req.params.uri, res })) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
areSubscriptionsExistValidator,
userSubscriptionListValidator,
userSubscriptionAddValidator,
userSubscriptionGetValidator,
userSubscriptionDeleteValidator
}
// ---------------------------------------------------------------------------
async function doesSubscriptionExist (options: {
uri: string
res: express.Response
state?: FollowState
}) {
const { uri, res, state } = options
let [ name, host ] = uri.split('@')
if (host === WEBSERVER.HOST) host = null
const user = res.locals.oauth.token.User
const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI({
actorId: user.Account.Actor.id,
targetName: name,
targetHost: host,
state
})
if (!subscription?.ActorFollowing.VideoChannel) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Subscription ${uri} not found.`
})
return false
}
res.locals.subscription = subscription
return true
}

View File

@@ -0,0 +1,522 @@
import { arrayify, forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ServerErrorCode, UserRole, UserUpdateMe } from '@peertube/peertube-models'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
import { isNSFWFlagsValid } from '@server/helpers/custom-validators/videos.js'
import { loadReservedActorName } from '@server/lib/local-actor.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { MUser } from '@server/types/models/user/user.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { exists, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isThemeNameValid } from '../../../helpers/custom-validators/plugins.js'
import {
isUserAdminFlagsValid,
isUserAutoPlayNextVideoValid,
isUserAutoPlayVideoValid,
isUserBlockedReasonValid,
isUserDescriptionValid,
isUserDisplayNameValid,
isUserEmailPublicValid,
isUserFeatureInfo,
isUserLanguage,
isUserNoModal,
isUserNSFWPolicyValid,
isUserP2PEnabledValid,
isUserPasswordValid,
isUserPasswordValidOrEmpty,
isUserRoleValid,
isUserUsernameValid,
isUserVideoLanguages,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
isUserVideosHistoryEnabledValid
} from '../../../helpers/custom-validators/users.js'
import { isVideoChannelUsernameValid } from '../../../helpers/custom-validators/video-channels.js'
import { logger } from '../../../helpers/logger.js'
import { isThemeRegistered } from '../../../lib/plugins/theme-utils.js'
import { Redis } from '../../../lib/redis.js'
import {
areValidationErrors,
checkEmailDoesNotAlreadyExist,
checkUserEmailExistPermissive,
checkUserIdExist,
checkUsernameOrEmailDoNotAlreadyExist,
doesChannelIdExist,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
export const usersListValidator = [
query('blocked')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should be a valid blocked boolean'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const usersAddValidator = [
body('username')
.custom(isUserUsernameValid)
.withMessage('Should have a valid username (lowercase alphanumeric characters)'),
body('password')
.custom(isUserPasswordValidOrEmpty),
body('email')
.isEmail(),
body('channelName')
.optional()
.custom(isVideoChannelUsernameValid),
body('videoQuota')
.optional()
.custom(isUserVideoQuotaValid),
body('videoQuotaDaily')
.optional()
.custom(isUserVideoQuotaDailyValid),
body('role')
.customSanitizer(toIntOrNull)
.custom(isUserRoleValid),
body('adminFlags')
.optional()
.custom(isUserAdminFlagsValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { omitBodyLog: true })) return
if (!await checkUsernameOrEmailDoNotAlreadyExist({ username: req.body.username, email: req.body.email, req, res })) return
const authUser = res.locals.oauth.token.User
if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'You can only create users (and not administrators or moderators)'
})
}
if (req.body.channelName) {
if (req.body.channelName === req.body.username) {
return res.fail({ message: 'Channel name cannot be the same as user username.' })
}
const existing = await loadReservedActorName(req.body.channelName)
if (existing) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: `Channel with name ${req.body.channelName} already exists.`
})
}
}
return next()
}
]
export const usersRemoveValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
if (user.username === 'root') {
return res.fail({ message: 'Cannot remove the root user' })
}
if (!checkCanModerate(user, res)) return
return next()
}
]
export const usersBlockToggleValidator = [
param('id')
.custom(isIdValid),
body('reason')
.optional()
.custom(isUserBlockedReasonValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
if (user.username === 'root') {
return res.fail({ message: 'Cannot block the root user' })
}
if (!checkCanModerate(user, res)) return
return next()
}
]
export const deleteMeValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
if (user.username === 'root') {
return res.fail({ message: 'You cannot delete your root account.' })
}
return next()
}
]
export const usersUpdateValidator = [
param('id').custom(isIdValid),
body('password')
.optional()
.custom(isUserPasswordValid),
body('email')
.optional()
.isEmail(),
body('emailVerified')
.optional()
.isBoolean(),
body('videoQuota')
.optional()
.custom(isUserVideoQuotaValid),
body('videoQuotaDaily')
.optional()
.custom(isUserVideoQuotaDailyValid),
body('pluginAuth')
.optional()
.exists(),
body('role')
.optional()
.customSanitizer(toIntOrNull)
.custom(isUserRoleValid),
body('adminFlags')
.optional()
.custom(isUserAdminFlagsValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { omitBodyLog: true })) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
if (user.username === 'root' && req.body.role !== undefined && user.role !== req.body.role) {
return res.fail({ message: 'Cannot change root role.' })
}
if (!checkCanModerate(user, res)) return
if (
req.body.email &&
req.body.email !== user.email &&
!await checkEmailDoesNotAlreadyExist({ email: req.body.email, req, res })
) return
return next()
}
]
export const usersUpdateMeValidator = [
body('displayName')
.optional()
.custom(isUserDisplayNameValid),
body('description')
.optional()
.custom(isUserDescriptionValid),
body('currentPassword')
.optional()
.custom(exists),
body('password')
.optional()
.custom(isUserPasswordValid),
body('emailPublic')
.optional()
.custom(isUserEmailPublicValid),
body('email')
.optional()
.isEmail(),
body('nsfwPolicy')
.optional()
.custom(isUserNSFWPolicyValid),
body('nsfwFlagsDisplayed')
.optional()
.custom(isNSFWFlagsValid),
body('nsfwFlagsHidden')
.optional()
.custom(isNSFWFlagsValid),
body('nsfwFlagsWarned')
.optional()
.custom(isNSFWFlagsValid),
body('nsfwFlagsBlurred')
.optional()
.custom(isNSFWFlagsValid),
body('autoPlayVideo')
.optional()
.custom(isUserAutoPlayVideoValid),
body('p2pEnabled')
.optional()
.custom(isUserP2PEnabledValid).withMessage('Should have a valid p2p enabled boolean'),
body('videoLanguages')
.optional()
.custom(isUserVideoLanguages),
body('language')
.optional()
.custom(isUserLanguage),
body('videosHistoryEnabled')
.optional()
.custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled boolean'),
body('theme')
.optional()
.custom(v => isThemeNameValid(v) && isThemeRegistered(v)),
body('noInstanceConfigWarningModal')
.optional()
.custom(v => isUserNoModal(v)).withMessage('Should have a valid noInstanceConfigWarningModal boolean'),
body('noWelcomeModal')
.optional()
.custom(v => isUserNoModal(v)).withMessage('Should have a valid noWelcomeModal boolean'),
body('noAccountSetupWarningModal')
.optional()
.custom(v => isUserNoModal(v)).withMessage('Should have a valid noAccountSetupWarningModal boolean'),
body('autoPlayNextVideo')
.optional()
.custom(v => isUserAutoPlayNextVideoValid(v)).withMessage('Should have a valid autoPlayNextVideo boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
const body = req.body as UserUpdateMe
if (
((body.nsfwFlagsBlurred || 0) & (body.nsfwFlagsWarned || 0)) !== 0 ||
((body.nsfwFlagsBlurred || 0) & (body.nsfwFlagsDisplayed || 0)) !== 0 ||
((body.nsfwFlagsBlurred || 0) & (body.nsfwFlagsHidden || 0)) !== 0 ||
((body.nsfwFlagsDisplayed || 0) & (body.nsfwFlagsHidden || 0)) !== 0 ||
((body.nsfwFlagsDisplayed || 0) & (body.nsfwFlagsWarned || 0)) !== 0 ||
((body.nsfwFlagsHidden || 0) & (body.nsfwFlagsWarned || 0)) !== 0
) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t(
'Cannot use same flags in nsfwFlagsDisplayed, nsfwFlagsHidden, nsfwFlagsBlurred and nsfwFlagsWarned at the same time'
)
})
}
if (body.password || body.email) {
if (user.pluginAuth !== null) {
return res.fail({ message: req.t('You cannot update your email or password that is associated with an external auth system.') })
}
if (!body.currentPassword) {
return res.fail({ message: req.t('currentPassword parameter is missing') })
}
if (await user.isPasswordMatch(body.currentPassword) !== true) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('currentPassword is invalid.'),
type: ServerErrorCode.CURRENT_PASSWORD_IS_INVALID
})
}
}
if (areValidationErrors(req, res, { omitBodyLog: true })) return
if (
body.email &&
body.email !== user.email &&
!await checkEmailDoesNotAlreadyExist({ email: body.email, req, res })
) return
return next()
}
]
export const usersGetValidator = [
param('id')
.custom(isIdValid),
query('withStats')
.optional()
.isBoolean().withMessage('Should have a valid withStats boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res, req.query.withStats)) return
return next()
}
]
export const usersVideoRatingValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'id')) return
return next()
}
]
export const listMyVideosValidator = [
query('channelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
query('channelNameOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid channelNameOneOf array'),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (
req.query.channelId &&
!await doesChannelIdExist({ id: req.query.channelId, checkCanManage: true, checkIsLocal: true, checkIsOwner: false, req, res })
) {
return
}
return next()
}
]
export const usersAskResetPasswordValidator = [
body('email')
.isEmail(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const { email } = await Hooks.wrapObject({
email: req.body.email
}, 'filter:api.users.ask-reset-password.body')
const exists = await checkUserEmailExistPermissive(email, res, false)
if (!exists) {
logger.debug('User with email %s does not exist (asking reset password).', email)
// Do not leak our emails
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
if (res.locals.user.pluginAuth) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Cannot recover password of a user that uses a plugin authentication.'
})
}
return next()
}
]
export const usersResetPasswordValidator = [
param('id')
.custom(isIdValid),
body('verificationString')
.not().isEmpty(),
body('password')
.custom(isUserPasswordValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id)
if (redisVerificationString !== req.body.verificationString) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Invalid verification string.'
})
}
return next()
}
]
export const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
return [
body('currentPassword').optional().custom(exists),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const user = res.locals.oauth.token.User
const isAdminOrModerator = user.role === UserRole.ADMINISTRATOR || user.role === UserRole.MODERATOR
const targetUserId = forceNumber(targetUserIdGetter(req))
// Admin/moderator action on another user, skip the password check
if (isAdminOrModerator && targetUserId !== user.id) {
return next()
}
if (!req.body.currentPassword) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'currentPassword is missing'
})
}
if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'currentPassword is invalid.',
type: ServerErrorCode.CURRENT_PASSWORD_IS_INVALID
})
}
return next()
}
]
}
export const userAutocompleteValidator = [
param('search')
.isString()
.not().isEmpty()
]
export const usersNewFeatureInfoReadValidator = [
body('feature')
.custom(isUserFeatureInfo),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function checkCanModerate (onUser: MUser, res: express.Response) {
const authUser = res.locals.oauth.token.User
if (authUser.role === UserRole.ADMINISTRATOR) return true
if (authUser.role === UserRole.MODERATOR && onUser.role === UserRole.USER) return true
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Users can only be managed by moderators or admins.'
})
return false
}

View File

@@ -0,0 +1,20 @@
export * from './video-blacklist.js'
export * from './video-captions.js'
export * from './video-channel-sync.js'
export * from './video-channels.js'
export * from './video-chapters.js'
export * from './video-comments.js'
export * from './video-files.js'
export * from './video-imports.js'
export * from './video-live.js'
export * from './video-ownership-changes.js'
export * from './video-passwords.js'
export * from './video-rates.js'
export * from './video-shares.js'
export * from './video-source.js'
export * from './video-stats.js'
export * from './video-studio.js'
export * from './video-token.js'
export * from './video-transcoding.js'
export * from './video-view.js'
export * from './videos.js'

View File

@@ -0,0 +1,2 @@
export * from './upload.js'
export * from './video-validators.js'

View File

@@ -0,0 +1,42 @@
import express from 'express'
import { logger } from '@server/helpers/logger.js'
import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode } from '@peertube/peertube-models'
export async function addDurationToVideoFileIfNeeded (options: {
res: express.Response
videoFile: { path: string, duration?: number }
middlewareName: string
}) {
const { res, middlewareName, videoFile } = options
try {
if (!videoFile.duration) await addDurationToVideo(res, videoFile)
} catch (err) {
logger.error('Invalid input file in ' + middlewareName, { err })
res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'Video file unreadable.'
})
return false
}
return true
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function addDurationToVideo (res: express.Response, videoFile: { path: string, duration?: number }) {
const probe = await ffprobePromise(videoFile.path)
res.locals.ffprobe = probe
const duration = await getVideoStreamDuration(videoFile.path, probe)
// FFmpeg may not be able to guess video duration
// For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
if (isNaN(duration)) videoFile.duration = 0
else videoFile.duration = duration
}

View File

@@ -0,0 +1,138 @@
import { canVideoFileBeEdited } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ServerErrorCode, ServerFilterHookName, VideoState, VideoStateType } from '@peertube/peertube-models'
import { isVideoFileMimeTypeValid, isVideoFileSizeValid } from '@server/helpers/custom-validators/videos.js'
import { logger } from '@server/helpers/logger.js'
import { CONSTRAINTS_FIELDS, VIDEO_STATES } from '@server/initializers/constants.js'
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { MUserAccountId, MVideo } from '@server/types/models/index.js'
import express from 'express'
import { checkUserQuota } from '../../shared/index.js'
export async function commonVideoFileChecks (options: {
req: express.Request
res: express.Response
user: MUserAccountId
videoFileSize: number
files: express.UploadFilesForCheck
}): Promise<boolean> {
const { req, res, user, videoFileSize, files } = options
if (!isVideoFileMimeTypeValid(files)) {
res.fail({
status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
message: req.t(
'This file is not supported. Please, make sure it is of the following type: {types}',
{ types: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') }
)
})
return false
}
if (!isVideoFileSizeValid(videoFileSize.toString())) {
res.fail({
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
message: req.t('This file is too large. It exceeds the maximum file size authorized'),
type: ServerErrorCode.MAX_FILE_SIZE_REACHED
})
return false
}
if (await checkUserQuota({ user, videoFileSize, req, res }) === false) return false
return true
}
export async function isVideoFileAccepted (options: {
req: express.Request
res: express.Response
videoFile: express.VideoLegacyUploadFile
hook: Extract<ServerFilterHookName, 'filter:api.video.upload.accept.result' | 'filter:api.video.update-file.accept.result'>
}) {
const { req, res, videoFile, hook } = options
// Check we accept this video
const acceptParameters = {
videoBody: req.body,
videoFile,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(isLocalVideoFileAccepted, acceptParameters, hook)
if (acceptedResult?.accepted !== true) {
logger.info('Refused local video file.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || req.t('Refused local video file')
})
return false
}
return true
}
export function checkVideoFileCanBeEdited (video: MVideo, req: express.Request, res: express.Response) {
if (video.isLive) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot edit a live video')
})
return false
}
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot edit video that is already waiting for transcoding/edition')
})
return false
}
if (!canVideoFileBeEdited(video.state)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Video state is not compatible with edition')
})
return false
}
return true
}
export function checkVideoCanBeTranscribed (video: MVideo, req: express.Request, res: express.Response) {
if (video.remote) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a remote video')
})
return false
}
if (video.isLive) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a live video')
})
return false
}
const incompatibleStates = new Set<VideoStateType>([
VideoState.TO_IMPORT,
VideoState.TO_EDIT,
VideoState.TO_MOVE_TO_EXTERNAL_STORAGE,
VideoState.TO_MOVE_TO_FILE_SYSTEM,
VideoState.TO_IMPORT_FAILED
])
if (incompatibleStates.has(video.state)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a video with "{state}" state', { state: VIDEO_STATES[video.state] })
})
return false
}
return true
}

View File

@@ -0,0 +1,87 @@
import express from 'express'
import { body, query } from 'express-validator'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../../helpers/custom-validators/video-blacklist.js'
import { areValidationErrors, doesVideoBlacklistExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const videosBlacklistRemoveValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoBlacklistExist(res.locals.videoAll.id, res)) return
return next()
}
]
const videosBlacklistAddValidator = [
isValidVideoIdParam('videoId'),
body('unfederate')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid unfederate boolean'),
body('reason')
.optional()
.custom(isVideoBlacklistReasonValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (req.body.unfederate === true && video.remote === true) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'You cannot unfederate a remote video.'
})
}
return next()
}
]
const videosBlacklistUpdateValidator = [
isValidVideoIdParam('videoId'),
body('reason')
.optional()
.custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoBlacklistExist(res.locals.videoAll.id, res)) return
return next()
}
]
const videosBlacklistFiltersValidator = [
query('type')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoBlacklistTypeValid).withMessage('Should have a valid video blacklist type attribute'),
query('search')
.optional()
.not()
.isEmpty().withMessage('Should have a valid search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosBlacklistAddValidator,
videosBlacklistRemoveValidator,
videosBlacklistUpdateValidator,
videosBlacklistFiltersValidator
}

View File

@@ -0,0 +1,170 @@
import { HttpStatusCode, ServerErrorCode, UserRight, VideoCaptionGenerate } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { body, param } from 'express-validator'
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
import {
areValidationErrors,
checkCanSeeVideo,
checkCanManageVideo,
doesVideoCaptionExist,
doesVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
import { checkVideoCanBeTranscribed } from './shared/video-validators.js'
export const addVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
.custom(isVideoCaptionLanguageValid).not().isEmpty(),
body('captionfile')
.custom((_, { req }) => isVideoCaptionFile(req.files, 'captionfile'))
.withMessage(
'This caption file is not supported or too large. ' +
`Please, make sure it is under ${CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max} bytes ` +
'and one of the following mimetypes: ' +
Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT).map(key => `${key} (${MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT[key]})`).join(', ')
),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) {
return cleanUpReqFiles(req)
}
return next()
}
]
export const generateVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
body('forceTranscription')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video transcription is disabled on this instance'
})
}
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (!checkVideoCanBeTranscribed(video, req, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) {
return
}
// Check the video has not already a caption
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
if (captions.length !== 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
type: ServerErrorCode.VIDEO_ALREADY_HAS_CAPTIONS,
message: 'This video already has captions'
})
}
// Bypass "video is already transcribed" check
const body = req.body as VideoCaptionGenerate
if (body.forceTranscription === true) {
if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can force transcription'
})
}
return next()
}
const info = await VideoJobInfoModel.load(video.id)
if (info && info.pendingTranscription > 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
type: ServerErrorCode.VIDEO_ALREADY_BEING_TRANSCRIBED,
message: 'This video is already being transcribed'
})
}
return next()
}
]
export const deleteVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
.custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCaptionExist(res.locals.videoAll, req.params.captionLanguage, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const listVideoCaptionsValidator = [
isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
const video = res.locals.onlyVideo
if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return
return next()
}
]

View File

@@ -0,0 +1,171 @@
import { HttpStatusCode, UserRight, VideoChannelCollaboratorState } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
import express from 'express'
import { body, param } from 'express-validator'
import { areValidationErrors, checkCanManageAccount, doesAccountHandleExist, doesChannelHandleExist } from '../shared/index.js'
import { CONFIG } from '@server/initializers/config.js'
export const channelListCollaboratorsValidator = [
param('handle').exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (
!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner: false, req, res })
) return
return next()
}
]
export const channelInviteCollaboratorsValidator = [
param('handle').exists(),
body('accountHandle').exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (
!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner: true, req, res })
) {
return
}
if (!await doesAccountHandleExist({ handle: req.body.accountHandle, req, res, checkIsLocal: true, checkCanManage: false })) return
const user = res.locals.oauth.token.User
if (user.Account.id === res.locals.account.id) {
res.fail({
message: req.t('Cannot invite the account owner of the channel to collaborate'),
status: HttpStatusCode.BAD_REQUEST_400
})
return
}
const collaborator = await VideoChannelCollaboratorModel.loadByCollaboratorAccountName({
accountName: req.body.accountHandle,
channelId: res.locals.videoChannel.id
})
res.locals.channelCollaborator = collaborator
if (collaborator && collaborator.state !== VideoChannelCollaboratorState.REJECTED) {
res.fail({
message: req.t('This account is already a collaborator or has a pending invitation for this channel'),
status: HttpStatusCode.CONFLICT_409
})
return
}
const count = await VideoChannelCollaboratorModel.countByChannel(res.locals.videoChannel.id)
if (count >= CONFIG.VIDEO_CHANNELS.MAX_COLLABORATORS_PER_CHANNEL) {
res.fail({
message: req.t(
'The maximum number of collaborators for this channel ({limit}) has been reached',
{ limit: CONFIG.VIDEO_CHANNELS.MAX_COLLABORATORS_PER_CHANNEL }
),
status: HttpStatusCode.BAD_REQUEST_400
})
return
}
return next()
}
]
export const channelAcceptOrRejectInviteCollaboratorsValidator = [
param('handle').exists(),
param('collaboratorId').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (
!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: false, checkIsLocal: true, checkIsOwner: false, req, res })
) {
return
}
if (!await doesChannelCollaboratorExist({ collaboratorId: +req.params.collaboratorId, channelHandle: req.params.handle, req, res })) {
return
}
const channelCollaborator = res.locals.channelCollaborator
if (channelCollaborator.state !== VideoChannelCollaboratorState.PENDING) {
res.fail({ message: req.t('Collaborator is not in pending state') })
return
}
const user = res.locals.oauth.token.User
if (channelCollaborator.accountId !== user.Account.id) {
res.fail({ message: req.t('Collaborator is not the current user') })
return
}
return next()
}
]
export const channelDeleteCollaboratorsValidator = [
param('handle').exists(),
param('collaboratorId').custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesChannelCollaboratorExist({ collaboratorId: +req.params.collaboratorId, channelHandle: req.params.handle, req, res })) {
return
}
const user = res.locals.oauth.token.User
const canManageCollaboratorAccount = checkCanManageAccount({
user,
account: res.locals.channelCollaborator.Account,
req,
res,
specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL
})
// Check this is the owner of the channel if the user is not the collaborator account
// Only the owner and the collaborator can delete the collaboration
const checkIsOwner = !canManageCollaboratorAccount
if (
!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner, req, res })
) {
return
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function doesChannelCollaboratorExist (options: {
collaboratorId: number
channelHandle: string
req: express.Request
res: express.Response
}) {
const { collaboratorId, channelHandle, req, res } = options
const channelCollaborator = await VideoChannelCollaboratorModel.loadByChannelHandle(collaboratorId, channelHandle)
if (!channelCollaborator) {
res.fail({
message: req.t('Channel collaborator does not exist'),
status: HttpStatusCode.NOT_FOUND_404
})
return false
}
res.locals.channelCollaborator = channelCollaborator
return true
}

View File

@@ -0,0 +1,82 @@
import { HttpStatusCode, VideoChannelSyncCreate } from '@peertube/peertube-models'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import * as express from 'express'
import { body, param, query } from 'express-validator'
import { areValidationErrors, doesChannelIdExist } from '../shared/index.js'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs.js'
export const ensureSyncIsEnabled = (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Synchronization is impossible as video channel synchronization is not enabled on the server'
})
}
return next()
}
export const videoChannelSyncValidator = [
body('externalChannelUrl')
.custom(isUrlValid),
body('videoChannelId')
.isInt(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: VideoChannelSyncCreate = req.body
if (!await doesChannelIdExist({ id: body.videoChannelId, checkCanManage: true, checkIsOwner: false, checkIsLocal: true, req, res })) {
return
}
const count = await VideoChannelSyncModel.countByAccount(res.locals.videoChannel.accountId)
if (count >= CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER) {
return res.fail({
message: `You cannot create more than ${CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER} channel synchronizations`
})
}
return next()
}
]
export const ensureSyncExists = [
param('id').exists().isInt().withMessage('Should have an sync id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoChannelSyncIdExist(+req.params.id, res)) return
if (
!await doesChannelIdExist({
id: res.locals.videoChannelSync.videoChannelId,
checkCanManage: true,
checkIsOwner: false,
checkIsLocal: true,
req,
res
})
) {
return
}
return next()
}
]
export const listAccountChannelsSyncValidator = [
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]

View File

@@ -0,0 +1,184 @@
import { HttpStatusCode, VideosImportInChannelCreate } from '@peertube/peertube-models'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { loadReservedActorName } from '@server/lib/local-actor.js'
import { MChannelAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc.js'
import {
isVideoChannelDescriptionValid,
isVideoChannelDisplayNameValid,
isVideoChannelSupportValid,
isVideoChannelUsernameValid
} from '../../../helpers/custom-validators/video-channels.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { areValidationErrors, checkUserQuota, doesChannelHandleExist } from '../shared/index.js'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs.js'
export const videoChannelsAddValidator = [
body('name')
.custom(isVideoChannelUsernameValid),
body('displayName')
.custom(isVideoChannelDisplayNameValid),
body('description')
.optional()
.custom(isVideoChannelDescriptionValid),
body('support')
.optional()
.custom(isVideoChannelSupportValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const actor = await loadReservedActorName(req.body.name)
if (actor) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t(
'Another actor (account/channel) with name {name} on this instance already exists or has already existed.',
{ name: req.body.name }
)
})
return false
}
const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id)
if (count >= CONFIG.VIDEO_CHANNELS.MAX_PER_USER) {
res.fail({ message: req.t('You cannot create more than {count} channels', { count: CONFIG.VIDEO_CHANNELS.MAX_PER_USER }) })
return false
}
return next()
}
]
export const videoChannelsUpdateValidator = [
body('displayName')
.optional()
.custom(isVideoChannelDisplayNameValid),
body('description')
.optional()
.custom(isVideoChannelDescriptionValid),
body('support')
.optional()
.custom(isVideoChannelSupportValid),
body('bulkVideosSupportUpdate')
.optional()
.custom(isBooleanValid).withMessage('Should have a valid bulkVideosSupportUpdate boolean field'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelsRemoveValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!await checkVideoChannelIsNotTheLastOne(res.locals.videoChannel, req, res)) return
return next()
}
]
export const videoChannelsHandleValidatorFactory = (options: {
checkIsLocal: boolean
checkCanManage: boolean
checkIsOwner: boolean
}) => {
const { checkIsLocal, checkCanManage, checkIsOwner } = options
return [
param('handle')
.exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage, checkIsLocal, checkIsOwner, req, res })) return
return next()
}
]
}
export const listAccountChannelsValidator = [
query('withStats')
.optional()
.customSanitizer(toBooleanOrNull),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelsListValidator = [
query('search')
.optional()
.not().isEmpty(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelImportVideosValidator = [
body('externalChannelUrl')
.custom(isUrlValid),
body('videoChannelSyncId')
.optional()
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: VideosImportInChannelCreate = req.body
if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Channel import is impossible as video upload via HTTP is not enabled on the server')
})
}
if (body.videoChannelSyncId && !await doesVideoChannelSyncIdExist(body.videoChannelSyncId, res)) return
if (res.locals.videoChannelSync && res.locals.videoChannelSync.videoChannelId !== res.locals.videoChannel.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('This channel sync is not owned by this channel')
})
}
const user = { id: res.locals.videoChannel.Account.userId }
if (!await checkUserQuota({ user, videoFileSize: 1, req, res })) return
return next()
}
]
// ---------------------------------------------------------------------------
async function checkVideoChannelIsNotTheLastOne (videoChannel: MChannelAccountDefault, req: express.Request, res: express.Response) {
const count = await VideoChannelModel.countByAccount(videoChannel.Account.id)
if (count <= 1) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot remove the last channel of this user')
})
return false
}
return true
}

View File

@@ -0,0 +1,41 @@
import express from 'express'
import { body } from 'express-validator'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { areValidationErrors, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
export const updateVideoChaptersValidator = [
isValidVideoIdParam('videoId'),
body('chapters')
.custom(areVideoChaptersValid)
.withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'You cannot add chapters to a live video'
})
}
// Check if the user who did the request is able to update video chapters (same right as updating the video)
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]

View File

@@ -0,0 +1,393 @@
import { arrayify } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight, VideoCommentPolicy } from '@peertube/peertube-models'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MUserAccountUrl } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import {
exists,
isBooleanValid,
isIdOrUUIDValid,
isIdValid,
toBooleanOrNull,
toCompleteUUID,
toIntOrNull
} from '../../../helpers/custom-validators/misc.js'
import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments.js'
import { logger } from '../../../helpers/logger.js'
import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video/index.js'
import {
areValidationErrors,
checkCanManageChannel,
checkCanManageVideo,
checkCanSeeVideo,
doesChannelIdExist,
doesVideoCommentExist,
doesVideoCommentThreadExist,
doesVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
export const listAllVideoCommentsForAdminValidator = [
...getCommonVideoCommentsValidators(),
query('isLocal')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid)
.withMessage('Should have a valid isLocal boolean'),
query('onLocalVideo')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid)
.withMessage('Should have a valid onLocalVideo boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'unsafe-only-immutable-attributes')) return
if (
req.query.videoChannelId &&
!await doesChannelIdExist({ id: req.query.videoChannelId, checkCanManage: true, checkIsOwner: false, checkIsLocal: true, req, res })
) return
return next()
}
]
export const listCommentsOnUserVideosValidator = [
...getCommonVideoCommentsValidators(),
query('isHeldForReview')
.optional()
.customSanitizer(toBooleanOrNull),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return
if (
req.query.videoChannelId &&
!await doesChannelIdExist({
id: req.query.videoChannelId,
checkCanManage: true,
checkIsLocal: true,
checkIsOwner: false,
req,
res,
specialRight: UserRight.SEE_ALL_COMMENTS
})
) return
const user = res.locals.oauth.token.User
const video = res.locals.videoAll
if (
video &&
!await checkCanManageVideo({ user, video, right: UserRight.SEE_ALL_COMMENTS, req, res, checkIsLocal: true, checkIsOwner: false })
) return
return next()
}
]
// ---------------------------------------------------------------------------
export const listVideoCommentThreadsValidator = [
isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return
return next()
}
]
export const listVideoThreadCommentsValidator = [
isValidVideoIdParam('videoId'),
param('threadId')
.custom(isIdValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return
return next()
}
]
export const addVideoCommentThreadValidator = [
isValidVideoIdParam('videoId'),
body('text')
.custom(isValidVideoCommentText),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return
if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return
return next()
}
]
export const addVideoCommentReplyValidator = [
isValidVideoIdParam('videoId'),
param('commentId').custom(isIdValid),
isValidVideoPasswordHeader(),
body('text').custom(isValidVideoCommentText),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return
if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, true)) return
return next()
}
]
export const videoCommentGetValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!canVideoBeFederated(res.locals.onlyVideo)) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (!await doesVideoCommentExist(req.params.commentId, res.locals.onlyVideo, res)) return
return next()
}
]
export const removeVideoCommentValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await checkCanDeleteVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) {
return
}
return next()
}
]
export const approveVideoCommentValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await checkCanApproveVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) {
return
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Video comments are disabled for this video.'
})
return false
}
return true
}
function checkCanDeleteVideoComment (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}): Promise<boolean> {
const { user, videoComment, req, res } = options
if (videoComment.isDeleted()) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('This comment is already deleted')
})
return Promise.resolve(false)
}
// Owner of the comment
if (videoComment.accountId === user.Account.id) {
return Promise.resolve(true)
}
return checkCanManageCommentsOfVideo(options)
}
function checkCanApproveVideoComment (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}): Promise<boolean> {
const { user, videoComment, req, res } = options
if (videoComment.isDeleted()) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('This comment is deleted')
})
return Promise.resolve(false)
}
if (videoComment.heldForReview !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('This comment is not held for review')
})
return Promise.resolve(false)
}
return checkCanManageCommentsOfVideo({ user, videoComment, req, res })
}
async function checkCanManageCommentsOfVideo (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}) {
const { user, videoComment, req, res } = options
if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return true
const channel = await VideoChannelModel.loadAndPopulateAccount(videoComment.Video.VideoChannel.id)
if (await checkCanManageChannel({ channel, user, req, res: null, checkCanManage: true, checkIsOwner: false })) return true
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('User does not have the permission to delete this comment')
})
return false
}
async function isVideoCommentAccepted (req: express.Request, res: express.Response, video: MVideoFullLight, isReply: boolean) {
const acceptParameters = {
video,
commentBody: req.body,
user: res.locals.oauth.token.User,
req
}
let acceptedResult: AcceptResult
if (isReply) {
const acceptReplyParameters = Object.assign(acceptParameters, { parentComment: res.locals.videoCommentFull })
acceptedResult = await Hooks.wrapFun(
isLocalVideoCommentReplyAccepted,
acceptReplyParameters,
'filter:api.video-comment-reply.create.accept.result'
)
} else {
acceptedResult = await Hooks.wrapFun(
isLocalVideoThreadAccepted,
acceptParameters,
'filter:api.video-thread.create.accept.result'
)
}
if (acceptedResult?.accepted !== true) {
logger.info('Refused local comment.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult?.errorMessage || 'Comment has been rejected.'
})
return false
}
return true
}
function getCommonVideoCommentsValidators () {
return [
query('search')
.optional()
.custom(exists),
query('searchAccount')
.optional()
.custom(exists),
query('searchVideo')
.optional()
.custom(exists),
query('videoId')
.optional()
.custom(toCompleteUUID)
.custom(isIdOrUUIDValid),
query('videoChannelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
query('autoTagOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid autoTagOneOf array')
]
}

View File

@@ -0,0 +1,168 @@
import { HttpStatusCode, VideoResolution } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { MVideo } from '@server/types/models/index.js'
import express from 'express'
import { param } from 'express-validator'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videoFilesDeleteWebVideoValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
if (!video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have Web Video files'
})
}
if (!video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete Web Video files since this video does not have HLS playlist'
})
}
return next()
}
]
export const videoFilesDeleteWebVideoFileValidator = [
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
const files = video.VideoFiles
if (!files.find(f => f.id === +req.params.videoFileId)) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'This video does not have this Web Video file id'
})
}
if (files.length === 1 && !video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete Web Video files since this video does not have HLS playlist'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export const videoFilesDeleteHLSValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
if (!video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have HLS files'
})
}
if (!video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete HLS playlist since this video does not have Web Video files'
})
}
return next()
}
]
export const videoFilesDeleteHLSFileValidator = [
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
const hls = video.getHLSPlaylist()
if (!hls) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have HLS files'
})
}
const hlsFiles = hls.VideoFiles
const file = hlsFiles.find(f => f.id === +req.params.videoFileId)
if (!file) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'This HLS playlist does not have this file id'
})
}
// Last file to delete
if (hlsFiles.length === 1 && !video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete last HLS playlist file since this video does not have Web Video files'
})
}
if (hls.hasAudioAndVideoSplitted() && file.resolution === VideoResolution.H_NOVIDEO) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete audio file of HLS playlist with splitted audio/video. Delete all the videos first'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function checkLocalVideo (video: MVideo, res: express.Response) {
if (video.remote) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete files of remote video'
})
return false
}
return true
}

View File

@@ -0,0 +1,266 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight, VideoImportCreate, VideoImportState } from '@peertube/peertube-models'
import { isResolvingToUnicastOnly } from '@server/helpers/dns.js'
import { isPreImportVideoAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { MUserAccountId, MVideoImportDefault } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports.js'
import { isValidPasswordProtectedPrivacy, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { areValidationErrors, checkCanManageVideo, doesChannelIdExist, doesVideoImportExist } from '../shared/index.js'
import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js'
export const videoImportAddValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('targetUrl')
.optional()
.custom(isVideoImportTargetUrlValid),
body('magnetUri')
.optional()
.custom(isVideoMagnetUriValid),
body('torrentfile')
.custom((value, { req }) => isVideoImportTorrentFile(req.files))
.withMessage(
'This torrent file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.EXTNAME.join(', ')
),
body('name')
.optional()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const torrentFile = req.files?.['torrentfile'] ? req.files['torrentfile'][0] : undefined
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('HTTP import is not enabled on this instance')
})
}
if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Torrent/magnet URI import is not enabled on this instance')
})
}
if (!await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) {
return cleanUpReqFiles(req)
}
// Check we have at least 1 required param
if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Should have a magnetUri or a targetUrl or a torrent file') })
}
if (req.body.targetUrl) {
const hostname = new URL(req.body.targetUrl).hostname
if (await isResolvingToUnicastOnly(hostname) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot use non unicast IP as targetUrl')
})
}
}
if (!await isImportAccepted(req, res)) return cleanUpReqFiles(req)
return next()
}
])
export const listMyVideoImportsValidator = [
query('id')
.optional()
.custom(isIdValid),
query('videoId')
.optional()
.custom(isIdValid),
query('videoChannelSyncId')
.optional()
.custom(isIdValid),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoImportDeleteValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(parseInt(req.params.id), res)) return
if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return
if (res.locals.videoImport.state === VideoImportState.PENDING) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot delete a pending video import. Cancel it or wait for the end of the import first')
})
}
return next()
}
]
export const videoImportCancelValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(forceNumber(req.params.id), res)) return
if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return
if (res.locals.videoImport.state !== VideoImportState.PENDING) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot cancel a non pending video import')
})
}
return next()
}
]
export const videoImportRetryValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(forceNumber(req.params.id), res)) return
if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return
if (res.locals.videoImport.state !== VideoImportState.FAILED) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry a non failed video import')
})
}
if (!res.locals.videoImport.Video) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry video import because the associated video metadata has been deleted')
})
}
if (res.locals.videoImport.attempts >= CONFIG.IMPORT.VIDEOS.MAX_ATTEMPTS) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry video import since it has reached the maximum number of attempts')
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function isImportAccepted (req: express.Request, res: express.Response) {
const body: VideoImportCreate = req.body
const hookName = body.targetUrl
? 'filter:api.video.pre-import-url.accept.result'
: 'filter:api.video.pre-import-torrent.accept.result'
// Check we accept this video
const acceptParameters = {
videoImportBody: body,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(
isPreImportVideoAccepted,
acceptParameters,
hookName
)
if (acceptedResult?.accepted !== true) {
logger.info('Refused to import video.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || req.t('Refused to import video')
})
return false
}
return true
}
async function checkCanManageImport (options: {
user: MUserAccountId
videoImport: MVideoImportDefault
req: express.Request
res: express.Response
}) {
const { user, videoImport, req, res } = options
if (user.hasRight(UserRight.MANAGE_VIDEO_IMPORTS) === true) return true
if (videoImport.userId === user.id) return true
if (
videoImport.Video &&
await checkCanManageVideo({
user,
video: videoImport.Video,
req,
res,
right: UserRight.MANAGE_VIDEO_IMPORTS,
checkIsLocal: true,
checkIsOwner: false
})
) {
return true
}
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage video import of another user')
})
return false
}

View File

@@ -0,0 +1,350 @@
import {
HttpStatusCode,
LiveVideoCreate,
LiveVideoLatencyMode,
LiveVideoUpdate,
ServerErrorCode,
UserRight,
VideoState
} from '@peertube/peertube-models'
import { areLiveSchedulesValid, isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { isLocalLiveVideoAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoModel } from '@server/models/video/video.js'
import express from 'express'
import { body } from 'express-validator'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { areValidationErrors, checkCanManageVideo, doesChannelIdExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js'
export const videoLiveGetValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
const videoLive = await VideoLiveModel.loadByVideoIdFull(res.locals.videoAll.id)
if (!videoLive) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Live video not found'
})
}
res.locals.videoLive = videoLive
return next()
}
]
export const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('name')
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('saveReplay')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid saveReplay boolean'),
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoReplayPrivacyValid),
body('permanentLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid permanentLive boolean'),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
body('schedules')
.optional()
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.LIVE.ENABLED !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Live is not enabled on this instance',
type: ServerErrorCode.LIVE_NOT_ENABLED
})
}
const body: LiveVideoCreate = req.body
if (hasValidSaveReplay(body) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Saving live replay is not enabled on this instance',
type: ServerErrorCode.LIVE_NOT_ALLOWING_REPLAY
})
}
if (hasValidLatencyMode(body) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
})
}
if (!await doesChannelIdExist({ id: body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) {
return cleanUpReqFiles(req)
}
if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) {
const totalInstanceLives = await VideoModel.countLives({ remote: false, mode: 'not-ended' })
if (totalInstanceLives >= CONFIG.LIVE.MAX_INSTANCE_LIVES) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot create this live because the max instance lives limit is reached.',
type: ServerErrorCode.MAX_INSTANCE_LIVES_LIMIT_REACHED
})
}
}
if (CONFIG.LIVE.MAX_USER_LIVES !== -1) {
const user = res.locals.oauth.token.User
const totalUserLives = await VideoModel.countLivesOfAccount(user.Account.id)
if (totalUserLives >= CONFIG.LIVE.MAX_USER_LIVES) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot create this live because the max user lives limit is reached.',
type: ServerErrorCode.MAX_USER_LIVES_LIMIT_REACHED
})
}
}
if (!await isLiveVideoAccepted(req, res)) return cleanUpReqFiles(req)
return next()
}
])
export const videoLiveUpdateValidator = [
body('saveReplay')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid saveReplay boolean'),
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoReplayPrivacyValid),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
body('schedules')
.optional()
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: LiveVideoUpdate = req.body
if (hasValidSaveReplay(body) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Saving live replay is not allowed by this instance'
})
}
if (hasValidLatencyMode(body) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
})
}
if (!checkLiveSettingsReplayConsistency({ res, body })) return
if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) {
return res.fail({ message: 'Cannot update a live that has already started' })
}
// Check the user can manage the live
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.GET_ANY_LIVE,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const videoLiveListSessionsValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
// Check the user can manage the live
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.GET_ANY_LIVE,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const videoLiveFindReplaySessionValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'id')) return
const session = await VideoLiveSessionModel.findSessionOfReplay(res.locals.videoId.id)
if (!session) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live replay found'
})
}
res.locals.videoLiveSession = session
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function isLiveVideoAccepted (req: express.Request, res: express.Response) {
// Check we accept this video
const acceptParameters = {
liveVideoBody: req.body,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(
isLocalLiveVideoAccepted,
acceptParameters,
'filter:api.live-video.create.accept.result'
)
if (acceptedResult?.accepted !== true) {
logger.info('Refused local live video.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || 'Refused local live video'
})
return false
}
return true
}
function hasValidSaveReplay (body: LiveVideoUpdate | LiveVideoCreate) {
if (CONFIG.LIVE.ALLOW_REPLAY !== true && body.saveReplay === true) return false
return true
}
function hasValidLatencyMode (body: LiveVideoUpdate | LiveVideoCreate) {
if (
CONFIG.LIVE.LATENCY_SETTING.ENABLED !== true &&
exists(body.latencyMode) &&
body.latencyMode !== LiveVideoLatencyMode.DEFAULT
) return false
return true
}
function checkLiveSettingsReplayConsistency (options: {
res: express.Response
body: LiveVideoUpdate
}) {
const { res, body } = options
// We now save replays of this live, so replay settings are mandatory
if (res.locals.videoLive.saveReplay !== true && body.saveReplay === true) {
if (!exists(body.replaySettings)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Replay settings are missing now the live replay is saved'
})
return false
}
if (!exists(body.replaySettings.privacy)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Privacy replay setting is missing now the live replay is saved'
})
return false
}
}
// Save replay was and is not enabled, so send an error the user if it specified replay settings
if ((!exists(body.saveReplay) && res.locals.videoLive.saveReplay === false) || body.saveReplay === false) {
if (exists(body.replaySettings)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot save replay settings since live replay is not enabled'
})
return false
}
}
return true
}

View File

@@ -0,0 +1,142 @@
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { AccountModel } from '@server/models/account/account.js'
import { MUserAccountId, MVideoChangeOwnershipFull, MVideoWithAllFiles } from '@server/types/models/index.js'
import express from 'express'
import { param } from 'express-validator'
import {
areValidationErrors,
checkCanManageAccount,
checkCanManageVideo,
checkUserQuota,
doesChangeVideoOwnershipExist,
doesChannelIdExist,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
export const videosChangeOwnershipValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
// Check if the user who did the request is able to change the ownership of the video
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoAll,
right: UserRight.CHANGE_VIDEO_OWNERSHIP,
checkIsOwner: true,
checkIsLocal: true,
req,
res
})
) return
const nextOwner = await AccountModel.loadLocalByName(req.body.username)
if (!nextOwner) {
res.fail({
message: req.t('{username} does not exist on {instanceName}', { username: req.body.username, instanceName: CONFIG.INSTANCE.NAME })
})
return
}
res.locals.nextOwner = nextOwner
return next()
}
]
export const videosTerminateChangeOwnershipValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesChangeVideoOwnershipExist(req.params.id, req, res)) return
// Check if the user who did the request is able to change the ownership of the video
if (
!checkCanTerminateOwnershipChange({
user: res.locals.oauth.token.User,
videoChangeOwnership: res.locals.videoChangeOwnership,
req,
res
})
) return
const videoChangeOwnership = res.locals.videoChangeOwnership
if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Ownership already accepted or refused')
})
return
}
return next()
}
]
export const videosAcceptChangeOwnershipValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const body = req.body as VideoChangeOwnershipAccept
if (!await doesChannelIdExist({ id: body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: true })) return
const videoChangeOwnership = res.locals.videoChangeOwnership
const video = videoChangeOwnership.Video
if (!await checkCanAccept(video, req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkCanAccept (video: MVideoWithAllFiles, req: express.Request, res: express.Response): Promise<boolean> {
if (video.isLive) {
if (video.state !== VideoState.WAITING_FOR_LIVE) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('You can accept an ownership change of a published live.')
})
return false
}
return true
}
const user = res.locals.oauth.token.User
if (!await checkUserQuota({ user, videoFileSize: video.getMaxQualityBytes(), req, res })) return false
return true
}
function checkCanTerminateOwnershipChange (options: {
user: MUserAccountId
videoChangeOwnership: MVideoChangeOwnershipFull
req: express.Request
res: express.Response
}) {
const { user, videoChangeOwnership, req, res } = options
if (!checkCanManageAccount({ user, account: videoChangeOwnership.NextOwner, req, res: null, specialRight: UserRight.MANAGE_USERS })) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot terminate an ownership change of another user')
})
return false
}
return true
}

View File

@@ -0,0 +1,119 @@
import { UserRight } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos.js'
import express from 'express'
import { body, param } from 'express-validator'
import {
areValidationErrors,
checkCanDeleteVideoPassword,
checkCanManageVideo,
doesVideoExist,
doesVideoPasswordExist,
isValidVideoIdParam,
checkVideoIsPasswordProtected
} from '../shared/index.js'
export const listVideoPasswordValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!checkVideoIsPasswordProtected(req, res)) return
// Check if the user who did the request is able to access video password list
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.SEE_ALL_VIDEOS,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const updateVideoPasswordListValidator = [
isValidVideoIdParam('videoId'),
body('passwords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkAddOrUpdatePasswords(req, res)) return
return next()
}
]
export const addVideoPasswordValidator = [
isValidVideoIdParam('videoId'),
body('password')
.isString()
.withMessage('Video password should be a string')
.notEmpty()
.withMessage('Password string should not be empty'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkAddOrUpdatePasswords(req, res)) return
return next()
}
]
export const removeVideoPasswordValidator = [
isValidVideoIdParam('videoId'),
param('passwordId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!checkVideoIsPasswordProtected(req, res)) return
if (!await doesVideoPasswordExist({ id: req.params.passwordId, req, res })) return
if (!await checkCanDeleteVideoPassword({ user: res.locals.oauth.token.User, video: res.locals.videoAll, req, res })) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkAddOrUpdatePasswords (req: express.Request, res: express.Response) {
if (!await doesVideoExist(req.params.videoId, res)) return false
if (!isValidPasswordProtectedPrivacy(req, res)) return false
// Check if the user who did the request is able to update video passwords
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return false
return true
}

Some files were not shown because too many files have changed in this diff Show More