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,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
}

View File

@@ -0,0 +1,551 @@
import { arrayify, forceNumber } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
UserRight,
UserRightType,
VideoPlaylistCreate,
VideoPlaylistPrivacy,
VideoPlaylistType,
VideoPlaylistUpdate
} from '@peertube/peertube-models'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
isArrayOf,
isIdOrUUIDValid,
isIdValid,
isUUIDValid,
toBooleanOrNull,
toCompleteUUID,
toIntArray,
toIntOrNull,
toValueOrNull
} from '../../../helpers/custom-validators/misc.js'
import {
isVideoPlaylistDescriptionValid,
isVideoPlaylistNameValid,
isVideoPlaylistPrivacyValid,
isVideoPlaylistTimestampValid,
isVideoPlaylistTypeValid
} from '../../../helpers/custom-validators/video-playlists.js'
import { isVideoImageValid } from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element.js'
import { MVideoPlaylistFullSummary } from '../../../types/models/video/video-playlist.js'
import { authenticateOrFail } from '../../auth.js'
import {
areValidationErrors,
checkCanManageAccount,
checkCanManageChannel,
doesChannelIdExist,
doesVideoExist,
doesVideoPlaylistExist,
isValidPlaylistIdParam,
VideoPlaylistFetchType
} from '../shared/index.js'
export const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
body('displayName')
.custom(isVideoPlaylistNameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const body: VideoPlaylistCreate = req.body
if (
body.videoChannelId &&
!await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (
!body.videoChannelId &&
(body.privacy === VideoPlaylistPrivacy.PUBLIC || body.privacy === VideoPlaylistPrivacy.UNLISTED)
) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot set "public" or "unlisted" a playlist that is not assigned to a channel.') })
}
return next()
}
])
export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
isValidPlaylistIdParam('playlistId'),
body('displayName')
.optional()
.custom(isVideoPlaylistNameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return cleanUpReqFiles(req)
const videoPlaylist = getPlaylist(res)
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return cleanUpReqFiles(req)
}
const body: VideoPlaylistUpdate = req.body
const newPrivacy = body.privacy || videoPlaylist.privacy
if (
newPrivacy === VideoPlaylistPrivacy.PUBLIC &&
(
(!videoPlaylist.videoChannelId && !body.videoChannelId) ||
body.videoChannelId === null
)
) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot set "public" a playlist that is not assigned to a channel.') })
}
if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot update a watch later playlist.') })
}
if (
body.videoChannelId &&
!await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (res.locals.videoChannel && res.locals.videoChannel.accountId !== videoPlaylist.ownerAccountId) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('The channel must be owned by the same account as the playlist')
})
return cleanUpReqFiles(req)
}
return next()
}
])
export const videoPlaylistsDeleteValidator = [
isValidPlaylistIdParam('playlistId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res })) return
const videoPlaylist = getPlaylist(res)
if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
return res.fail({ message: req.t('Cannot delete a watch later playlist.') })
}
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
return next()
}
]
export const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
return [
isValidPlaylistIdParam('playlistId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType })) return
const videoPlaylist = res.locals.videoPlaylistFull || res.locals.videoPlaylistSummary
// Playlist is unlisted, check we used the uuid to fetch it
if (videoPlaylist.privacy === VideoPlaylistPrivacy.UNLISTED) {
if (isUUIDValid(req.params.playlistId)) return next()
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Playlist not found')
})
}
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
if (!await authenticateOrFail({ req, res })) return
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
}
return next()
}
]
}
export const videoPlaylistsSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoPlaylistsAccountValidator = [
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
query('channelNameOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid channelNameOneOf array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoPlaylistsAddVideoValidator = [
isValidPlaylistIdParam('playlistId'),
body('videoId')
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid/short uuid'),
body('startTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
body('stopTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
if (!await doesVideoExist(req.body.videoId, res, 'only-video-and-blacklist')) return
const videoPlaylist = getPlaylist(res)
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
return next()
}
]
export const videoPlaylistsUpdateOrRemoveVideoValidator = [
isValidPlaylistIdParam('playlistId'),
param('playlistElementId')
.customSanitizer(toCompleteUUID)
.custom(isIdValid).withMessage('Should have an element id/uuid/short uuid'),
body('startTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
body('stopTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
const videoPlaylist = getPlaylist(res)
const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId)
if (!videoPlaylistElement) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video playlist element not found')
})
return
}
res.locals.videoPlaylistElement = videoPlaylistElement
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) return
return next()
}
]
export const videoPlaylistElementAPGetValidator = [
isValidPlaylistIdParam('playlistId'),
param('playlistElementId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const playlistElementId = forceNumber(req.params.playlistElementId)
const playlistId = req.params.playlistId
const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndElementIdForAP(playlistId, playlistElementId)
if (!videoPlaylistElement) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video playlist element not found')
})
return
}
if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot get this private video playlist.')
})
}
res.locals.videoPlaylistElementAP = videoPlaylistElement
return next()
}
]
export const videoPlaylistsReorderInChannelValidator = [
body('startPosition')
.isInt({ min: 1 }),
body('insertAfterPosition')
.isInt({ min: 0 }),
body('reorderLength')
.optional()
.isInt({ min: 1 }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const nextPosition = await VideoPlaylistModel.getNextPositionOf({ videoChannelId: res.locals.videoChannel.id })
const startPosition: number = req.body.startPosition
const insertAfterPosition: number = req.body.insertAfterPosition
const reorderLength: number = req.body.reorderLength
if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
res.fail({
message: req.t('Start position or insert after position exceed the channel limits (max: {max})', { max: nextPosition - 1 })
})
return
}
if (reorderLength && reorderLength + startPosition > nextPosition) {
res.fail({
message: req.t(
'Reorder length with this start position exceeds the channel limits (max: {max})',
{ max: nextPosition - startPosition }
)
})
return
}
return next()
}
]
export const videoPlaylistsReorderVideosValidator = [
isValidPlaylistIdParam('playlistId'),
body('startPosition')
.isInt({ min: 1 }),
body('insertAfterPosition')
.isInt({ min: 0 }),
body('reorderLength')
.optional()
.isInt({ min: 1 }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
const videoPlaylist = getPlaylist(res)
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) return
const nextPosition = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id)
const startPosition: number = req.body.startPosition
const insertAfterPosition: number = req.body.insertAfterPosition
const reorderLength: number = req.body.reorderLength
if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
res.fail({
message: req.t('Start position or insert after position exceed the playlist limits (max: {max})', { max: nextPosition - 1 })
})
return
}
if (reorderLength && reorderLength + startPosition > nextPosition) {
res.fail({
message: req.t('Reorder length with this start position exceeds the playlist limits (max: {max})', {
max: nextPosition - startPosition
})
})
return
}
return next()
}
]
export const commonVideoPlaylistFiltersValidator = [
query('playlistType')
.optional()
.custom(isVideoPlaylistTypeValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const doVideosInPlaylistExistValidator = [
query('videoIds')
.customSanitizer(toIntArray)
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function getCommonPlaylistEditAttributes () {
return [
body('thumbnailfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
.withMessage(
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
),
body('description')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoPlaylistDescriptionValid),
body('privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoPlaylistPrivacyValid),
body('videoChannelId')
.optional()
.customSanitizer(toIntOrNull)
] as (ValidationChain | ExpressPromiseHandler)[]
}
async function checkCanManagePlaylist (options: {
user: MUserAccountId
videoPlaylist: MVideoPlaylistFullSummary
right: UserRightType
req: express.Request
res: express.Response
}) {
const { user, videoPlaylist, right, res, req } = options
if (videoPlaylist.isLocal() === false) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage video playlist of another server.')
})
return false
}
if (checkCanManageAccount({ user, account: videoPlaylist.OwnerAccount, specialRight: right, req, res: null })) return true
if (videoPlaylist.videoChannelId) {
const channel = await VideoChannelModel.loadAndPopulateAccount(videoPlaylist.videoChannelId)
if (
await checkCanManageChannel({
channel,
user,
req,
res: null,
checkCanManage: true,
checkIsOwner: false,
specialRight: right
})
) {
return true
}
}
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage video playlist of another user')
})
return false
}
function getPlaylist (res: express.Response) {
return res.locals.videoPlaylistFull || res.locals.videoPlaylistSummary
}

View File

@@ -0,0 +1,72 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { HttpStatusCode, VideoRateType } from '@peertube/peertube-models'
import { isAccountNameValid } from '../../../helpers/custom-validators/accounts.js'
import { isIdValid } from '../../../helpers/custom-validators/misc.js'
import { isRatingValid } from '../../../helpers/custom-validators/video-rates.js'
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared/index.js'
const videoUpdateRateValidator = [
isValidVideoIdParam('id'),
body('rating')
.custom(isVideoRatingTypeValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.id, video: res.locals.videoAll })) return
return next()
}
]
const getAccountVideoRateValidatorFactory = function (rateType: VideoRateType) {
return [
param('accountName')
.custom(isAccountNameValid),
param('videoId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.accountName, +req.params.videoId)
if (!rate) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video rate not found'
})
}
res.locals.accountVideoRate = rate
return next()
}
]
}
const videoRatingValidator = [
query('rating')
.optional()
.custom(isRatingValid).withMessage('Value must be one of "like" or "dislike"'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoUpdateRateValidator,
getAccountVideoRateValidatorFactory,
videoRatingValidator
}

View File

@@ -0,0 +1,28 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
import express from 'express'
import { param } from 'express-validator'
import { isIdValid } from '../../../helpers/custom-validators/misc.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videosShareValidator = [
isValidVideoIdParam('id'),
param('actorId')
.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 (!canVideoBeFederated(video)) res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const share = await VideoShareModel.load(req.params.actorId, video.id)
if (!share) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
res.locals.videoShare = share
return next()
}
]

View File

@@ -0,0 +1,135 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { getVideoWithAttributes } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideoFullLight } from '@server/types/models/index.js'
import { Metadata as UploadXMetadata } from '@uploadx/core'
import express from 'express'
import { param } from 'express-validator'
import {
areValidationErrors,
checkCanAccessVideoSourceFile,
checkCanManageVideo,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
export const videoSourceGetLatestValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, 'all')) return
const video = getVideoWithAttributes(res) as MVideoFullLight
const user = res.locals.oauth.token.User
if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) {
return
}
res.locals.videoSource = await VideoSourceModel.loadLatest(video.id)
if (!res.locals.videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video source not found')
})
}
return next()
}
]
export const replaceVideoSourceResumableValidator = [
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 checkCanUpdateVideoFile({ req, res })) {
return cleanup()
}
if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'updateVideoFileResumableValidator' })) {
return cleanup()
}
if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.update-file.accept.result' })) {
return cleanup()
}
res.locals.updateVideoFileResumable = { ...file, originalname: file.filename }
return next()
}
]
export const replaceVideoSourceResumableInitValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
if (!await checkCanUpdateVideoFile({ req, res })) return
const fileMetadata = res.locals.uploadVideoFileResumableMetadata
const files = { videofile: [ fileMetadata ] }
if (await commonVideoFileChecks({ req, res, user, videoFileSize: fileMetadata.size, files }) === false) return
return next()
}
]
export const originalVideoFileDownloadValidator = [
param('filename').exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const videoSource = await VideoSourceModel.loadByKeptOriginalFilename(req.params.filename)
if (!videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Original video file not found')
})
}
if (!await checkCanAccessVideoSourceFile({ req, res, videoId: videoSource.videoId })) return
res.locals.videoSource = videoSource
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkCanUpdateVideoFile (options: {
req: express.Request
res: express.Response
}) {
const { req, res } = options
if (!CONFIG.VIDEO_FILE.UPDATE.ENABLED) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Updating the file of an existing video is not allowed on this instance')
})
return false
}
if (!await doesVideoExist(req.params.id, res)) return false
const user = res.locals.oauth.token.User
const video = res.locals.videoAll
if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) {
return false
}
if (!checkVideoFileCanBeEdited(video, req, res)) return false
return true
}

View File

@@ -0,0 +1,115 @@
import express from 'express'
import { param, query } from 'express-validator'
import { isDateValid } from '@server/helpers/custom-validators/misc.js'
import { isValidStatTimeserieMetric } from '@server/helpers/custom-validators/video-stats.js'
import { STATS_TIMESERIE } from '@server/initializers/constants.js'
import { HttpStatusCode, UserRight, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
import { areValidationErrors, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videoOverallOrUserAgentStatsValidator = [
isValidVideoIdParam('videoId'),
query('startDate')
.optional()
.custom(isDateValid),
query('endDate')
.optional()
.custom(isDateValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
return next()
}
]
export const videoRetentionStatsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot get retention stats of live video'
})
}
return next()
}
]
export const videoTimeseriesStatsValidator = [
isValidVideoIdParam('videoId'),
param('metric')
.custom(isValidStatTimeserieMetric),
query('startDate')
.optional()
.custom(isDateValid),
query('endDate')
.optional()
.custom(isDateValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
const query: VideoStatsTimeserieQuery = req.query
if (
(query.startDate && !query.endDate) ||
(!query.startDate && query.endDate)
) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Both start date and end date should be defined if one of them is specified'
})
}
if (query.startDate && getIntervalByDays(query.startDate, query.endDate) > STATS_TIMESERIE.MAX_DAYS) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Start date and end date interval is too big'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function commonStatsCheck (req: express.Request, res: express.Response) {
if (!await doesVideoExist(req.params.videoId, res, 'all')) return false
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoAll,
right: UserRight.SEE_ALL_VIDEOS,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) {
return false
}
return true
}
function getIntervalByDays (startDateString: string, endDateString: string) {
const startDate = new Date(startDateString)
const endDate = new Date(endDateString)
return (endDate.getTime() - startDate.getTime()) / 1000 / 86400
}

View File

@@ -0,0 +1,119 @@
import express from 'express'
import { body, param } from 'express-validator'
import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc.js'
import {
isStudioCutTaskValid,
isStudioTaskAddIntroOutroValid,
isStudioTaskAddWatermarkValid,
isValidStudioTasksArray
} from '@server/helpers/custom-validators/video-studio.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio.js'
import { isAudioFile } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode, UserRight, VideoStudioCreateEdition, VideoStudioTask } from '@peertube/peertube-models'
import { areValidationErrors, checkCanManageVideo, checkUserQuota, doesVideoExist } from '../shared/index.js'
import { checkVideoFileCanBeEdited } from './shared/index.js'
const videoStudioAddEditionValidator = [
param('videoId')
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid/short uuid'),
body('tasks')
.custom(isValidStudioTasksArray).withMessage('Should have a valid array of tasks'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (CONFIG.VIDEO_STUDIO.ENABLED !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video studio is disabled on this instance'
})
return cleanUpReqFiles(req)
}
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
const body: VideoStudioCreateEdition = req.body
const files = req.files as Express.Multer.File[]
const video = res.locals.videoAll
const videoIsAudio = video.hasAudio() && !video.hasVideo()
for (let i = 0; i < body.tasks.length; i++) {
const task = body.tasks[i]
if (!checkTask(req, task, i)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid`
})
return cleanUpReqFiles(req)
}
if (videoIsAudio) {
if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid: video does not contain a video stream`
})
return cleanUpReqFiles(req)
}
}
if (task.name === 'add-intro' || task.name === 'add-outro') {
const filePath = getTaskFileFromReq(files, i).path
// Our concat filter needs a video stream
if (await isAudioFile(filePath)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid: input file does not contain a video stream`
})
return cleanUpReqFiles(req)
}
}
}
if (!checkVideoFileCanBeEdited(video, req, res)) return cleanUpReqFiles(req)
const user = res.locals.oauth.token.User
if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) {
return cleanUpReqFiles(req)
}
// Try to make an approximation of bytes added by the intro/outro
const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFileFromReq(files, i).path)
if (await checkUserQuota({ user, videoFileSize: additionalBytes, req, res }) === false) return cleanUpReqFiles(req)
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoStudioAddEditionValidator
}
// ---------------------------------------------------------------------------
const taskCheckers: {
[id in VideoStudioTask['name']]: (task: VideoStudioTask, indice?: number, files?: Express.Multer.File[]) => boolean
} = {
'cut': isStudioCutTaskValid,
'add-intro': isStudioTaskAddIntroOutroValid,
'add-outro': isStudioTaskAddIntroOutroValid,
'add-watermark': isStudioTaskAddWatermarkValid
}
function checkTask (req: express.Request, task: VideoStudioTask, indice?: number) {
const checker = taskCheckers[task.name]
if (!checker) return false
return checker(task, indice, req.files as Express.Multer.File[])
}

View File

@@ -0,0 +1,23 @@
import express from 'express'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
const videoFileTokenValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const video = res.locals.onlyVideo
if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: 'Not authenticated'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoFileTokenValidator
}

View File

@@ -0,0 +1,57 @@
import { HttpStatusCode, ServerErrorCode, VideoTranscodingCreate } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { isValidCreateTranscodingType } from '@server/helpers/custom-validators/video-transcoding.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { body } from 'express-validator'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
import { checkVideoCanBeTranscribed } from './shared/video-validators.js'
const createTranscodingValidator = [
isValidVideoIdParam('videoId'),
body('transcodingType')
.custom(isValidCreateTranscodingType),
body('forceTranscoding')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
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 video = res.locals.videoAll
if (!checkVideoCanBeTranscribed(video, req, res)) return
if (CONFIG.TRANSCODING.ENABLED !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot run transcoding job because transcoding is disabled on this instance'
})
}
const body = req.body as VideoTranscodingCreate
if (body.forceTranscoding === true) return next()
const info = await VideoJobInfoModel.load(video.id)
if (info && info.pendingTranscode > 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
type: ServerErrorCode.VIDEO_ALREADY_BEING_TRANSCODED,
message: 'This video is already being transcoded'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
createTranscodingValidator
}

View File

@@ -0,0 +1,83 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import {
isVideoTimeValid,
isVideoViewEvent,
isVideoViewUAInfo,
toVideoViewUADeviceOrNull
} from '@server/helpers/custom-validators/video-view.js'
import { getCachedVideoDuration } from '@server/lib/video.js'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
import express from 'express'
import { body, param } from 'express-validator'
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const tags = [ 'views' ]
export const getVideoLocalViewerValidator = [
param('localViewerId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const localViewer = await LocalVideoViewerModel.loadFullById(+req.params.localViewerId)
if (!localViewer) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Local viewer not found',
tags
})
}
res.locals.localViewerFull = localViewer
return next()
}
]
export const videoViewValidator = [
isValidVideoIdParam('videoId'),
body('currentTime')
.customSanitizer(toIntOrNull)
.isInt(),
body('sessionId')
.optional()
.isAlphanumeric(undefined, { ignore: '-' }),
body('viewEvent')
.optional()
.custom(isVideoViewEvent),
body('client')
.optional()
.custom(isVideoViewUAInfo),
body('device')
.optional()
.customSanitizer(toVideoViewUADeviceOrNull),
body('operatingSystem')
.optional()
.custom(isVideoViewUAInfo),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return
const video = res.locals.onlyImmutableVideo
const { duration } = await getCachedVideoDuration(video.id)
const currentTime = req.body.currentTime
if (!isVideoTimeValid(currentTime, duration)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Current time ${currentTime} is invalid (video ${video.uuid} duration: ${duration})`,
logLevel: 'warn',
tags
})
}
return next()
}
]

View File

@@ -0,0 +1,667 @@
import { arrayify } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
ServerErrorCode,
UserRight,
VideoCreateUpdateCommon,
VideosCommonQuery,
VideoState
} from '@peertube/peertube-models'
import { isHostValid } from '@server/helpers/custom-validators/servers.js'
import { VideoLoadType } from '@server/lib/model-loaders/video.js'
import { Redis } from '@server/lib/redis.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
exists,
hasArrayLength,
isBooleanValid,
isDateValid,
isFileValid,
isIdValid,
isNotEmptyIntArray,
toBooleanOrNull,
toIntArray,
toIntOrNull,
toValueOrNull
} from '../../../helpers/custom-validators/misc.js'
import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search.js'
import {
areVideoTagsValid,
isNSFWFlagsValid,
isNSFWSummaryValid,
isScheduleVideoUpdatePrivacyValid,
isValidPasswordProtectedPrivacy,
isVideoCategoryValid,
isVideoCommentsPolicyValid,
isVideoDescriptionValid,
isVideoImageValid,
isVideoIncludeValid,
isVideoLanguageValid,
isVideoLicenceValid,
isVideoNameValid,
isVideoOriginallyPublishedAtValid,
isVideoPrivacyValid,
isVideoSourceFilenameValid,
isVideoSupportValid
} from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getVideoWithAttributes } from '../../../helpers/video.js'
import { CONFIG } from '../../../initializers/config.js'
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants.js'
import { VideoModel } from '../../../models/video/video.js'
import {
areValidationErrors,
checkCanAccessVideoStaticFiles,
checkCanManageVideo,
checkCanSeeVideo,
doesChannelIdExist,
doesVideoExist,
doesVideoFileOfVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
const getVideoUploadCommonValidator = () => [
body('name')
.trim()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
body('generateTranscription')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid generateTranscription boolean')
]
export const videosAddLegacyValidator = [
...getCommonVideoEditAttributes(),
...getVideoUploadCommonValidator(),
body('videofile')
.custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
.withMessage('Should have a file'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const videoFile: express.VideoLegacyUploadFile = req.files['videofile'][0]
const user = res.locals.oauth.token.User
if (
!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files }) ||
!isValidPasswordProtectedPrivacy(req, res) ||
!await addDurationToVideoFileIfNeeded({ videoFile, res, middlewareName: 'videosAddLegacyValidator' }) ||
!await isVideoFileAccepted({ req, res, videoFile, hook: 'filter:api.video.upload.accept.result' })
) {
return cleanUpReqFiles(req)
}
return next()
}
]
/**
* Gets called after the last PUT request
*/
export const videosAddResumableValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const file = buildUploadXFile(req.body as express.CustomUploadXFile<express.UploadNewVideoXFileMetadata>)
const cleanup = () => {
safeUploadXCleanup(file)
Redis.Instance.deleteUploadSession(req.query.upload_id)
.catch(err => logger.error('Cannot delete upload session', { err }))
}
const uploadId = req.query.upload_id
const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
if (sessionExists) {
res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
return res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: req.t('The upload is already being processed')
})
}
await Redis.Instance.setUploadSession(uploadId)
if (
!await doesChannelIdExist({ id: file.metadata.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanup()
}
if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'videosAddResumableValidator' })) return cleanup()
if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.upload.accept.result' })) return cleanup()
res.locals.uploadVideoFileResumable = { ...file, originalname: file.filename }
return next()
}
]
/**
* File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
* see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
*
* Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
* see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
*/
export const videosAddResumableInitValidator = [
...getCommonVideoEditAttributes(),
...getVideoUploadCommonValidator(),
body('filename')
.custom(isVideoSourceFilenameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
const cleanup = () => cleanUpReqFiles(req)
logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
parameters: req.body,
headers: req.headers,
files: req.files
})
if (areValidationErrors(req, res, { omitLog: true })) return cleanup()
const fileMetadata = res.locals.uploadVideoFileResumableMetadata
const files = { videofile: [ fileMetadata ] }
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: fileMetadata.size, files })) return cleanup()
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
// Multer required unsetting the Content-Type, now we can set it for node-uploadx
req.headers['content-type'] = 'application/json; charset=utf-8'
// Place thumbnail/previewfile in metadata so that uploadx saves it in .META
if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
if (req.files?.['thumbnailfile']) req.body.thumbnailfile = req.files['thumbnailfile']
return next()
}
]
export const videosUpdateValidator = getCommonVideoEditAttributes().concat([
isValidVideoIdParam('id'),
body('name')
.optional()
.trim()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('channelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
const video = res.locals.videoAll
if (exists(req.body.privacy) && video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
return res.fail({ message: req.t('Cannot update privacy of a live that has already started') })
}
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) {
return cleanUpReqFiles(req)
}
if (
req.body.channelId &&
!await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (res.locals.videoChannel && res.locals.videoChannel.accountId !== video.VideoChannel.accountId) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('The channel must belong to the same account as the original channel')
})
return cleanUpReqFiles(req)
}
return next()
}
])
export async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
const video = getVideoWithAttributes(res)
// Anybody can watch local videos
if (video.isLocal() === true) return next()
// Logged user
if (res.locals.oauth) {
// Users can search or watch remote videos
if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
}
// Anybody can search or watch remote videos
if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
// Check our instance follows an actor that shared this video
if (await VideoModel.checkVideoHasInstanceFollow(video.id) === true) return next()
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot get this video regarding follow constraints'),
type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
data: {
originUrl: video.url
}
})
}
type FetchType = Extract<VideoLoadType, 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes'>
export const videosCustomGetValidator = (fetchType: FetchType) => {
return [
isValidVideoIdParam('id'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, fetchType)) return
// Controllers does not need to check video rights
if (fetchType === 'unsafe-only-immutable-attributes') return next()
const video = getVideoWithAttributes(res) as MVideoFullLight
if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return
return next()
}
]
}
export const videosGetValidator = videosCustomGetValidator('all')
export const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
return next()
}
])
export const videosDownloadValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, 'all')) return
const video = getVideoWithAttributes(res)
if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return
return next()
}
]
export const videosGenerateDownloadValidator = [
query('videoFileIds')
.customSanitizer(toIntArray)
.custom(isNotEmptyIntArray)
.custom(v => hasArrayLength(v, { max: 2 })),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videosRemoveValidator = [
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
// Check if the user who did the request is able to delete the video
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoAll,
right: UserRight.REMOVE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const videosOverviewValidator = [
query('page')
.optional()
.isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export function getCommonVideoEditAttributes () {
return [
body('thumbnailfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('previewfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('category')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoCategoryValid),
body('licence')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoLicenceValid),
body('language')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoLanguageValid),
body('nsfw')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
body('nsfwFlags')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
body('nsfwSummary')
.optional()
.customSanitizer(toValueOrNull)
.custom(isNSFWSummaryValid),
body('waitTranscoding')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
body('privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoPrivacyValid),
body('description')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoDescriptionValid),
body('support')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoSupportValid),
body('tags')
.optional()
.customSanitizer(toValueOrNull)
.custom(areVideoTagsValid)
.withMessage(
`Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
`${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
),
body('commentsPolicy')
.optional()
.custom(isVideoCommentsPolicyValid),
body('downloadEnabled')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
body('originallyPublishedAt')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoOriginallyPublishedAtValid),
body('scheduleUpdate')
.optional()
.customSanitizer(toValueOrNull),
body('scheduleUpdate.updateAt')
.optional()
.custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
body('scheduleUpdate.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isScheduleVideoUpdatePrivacyValid)
] as (ValidationChain | ExpressPromiseHandler)[]
}
export const commonVideosFiltersValidatorFactory = (options: {
allowPrivacyFilterForAllUsers?: boolean
} = {}) => {
return [
query('categoryOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
query('licenceOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
query('languageOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
query('privacyOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
query('tagsOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
query('tagsAllOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
query('nsfw')
.optional()
.custom(isBooleanBothQueryValid),
query('nsfwFlagsIncluded')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
query('nsfwFlagsExcluded')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
query('isLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
query('includeScheduledLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid includeScheduledLive boolean'),
query('include')
.optional()
.custom(isVideoIncludeValid),
query('isLocal')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
query('hasHLSFiles')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
query('hasWebVideoFiles')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid hasWebVideoFiles boolean'),
query('skipCount')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
query('search')
.optional()
.custom(exists),
query('excludeAlreadyWatched')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
query('autoTagOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid autoTagOneOf array'),
query('host')
.optional()
.custom(isHostValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const query = req.query as VideosCommonQuery
if (((query.nsfwFlagsExcluded || 0) & (query.nsfwFlagsIncluded || 0)) !== 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot use same flags in nsfwFlagsIncluded and nsfwFlagsExcluded at the same time')
})
}
const user = res.locals.oauth?.token.User
if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
if (query.include || (options.allowPrivacyFilterForAllUsers !== true && query.privacyOneOf) || query.autoTagOneOf) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('You are not allowed to see all videos, specify a custom include or auto tags filter')
})
}
}
if (!user && exists(query.excludeAlreadyWatched)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
})
return false
}
if (req.query.filter) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('"filter" query parameter is not supported anymore by PeerTube. Please use "isLocal" and "include" instead')
})
return false
}
return next()
}
]
}
export function areErrorsInNSFW (req: express.Request, res: express.Response) {
const body = req.body as VideoCreateUpdateCommon
if (!body.nsfw) {
if (body.nsfwFlags) {
res.fail({ message: req.t('Cannot set nsfwFlags if the video is not NSFW') })
return true
}
if (body.nsfwSummary) {
res.fail({ message: req.t('Cannot set nsfwSummary if the video is not NSFW') })
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
if (req.body.scheduleUpdate) {
if (!req.body.scheduleUpdate.updateAt) {
logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
res.fail({ message: req.t('"scheduleUpdate.updateAt" parameter is mandatory') })
return true
}
}
return false
}
async function commonVideoChecksPass (options: {
req: express.Request
res: express.Response
user: MUserAccountId
videoFileSize: number
files: express.UploadFilesForCheck
}): Promise<boolean> {
const { req, res } = options
if (areErrorsInScheduleUpdate(req, res)) return false
if (areErrorsInNSFW(req, res)) return false
if (!await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) {
return false
}
if (!await commonVideoFileChecks(options)) return false
return true
}

View File

@@ -0,0 +1,126 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { Awaitable } from '@peertube/peertube-typescript-utils'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { areWatchedWordsValid, isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
import { MAccountId, MWatchedWordsList } from '@server/types/models/index.js'
import express from 'express'
import { ValidationChain, body, param } from 'express-validator'
import { doesAccountHandleExist } from './shared/accounts.js'
import { areValidationErrors } from './shared/utils.js'
export const manageAccountWatchedWordsListValidator = [
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 function getWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return [
param('listId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const watchedWordsList = await WatchedWordsListModel.load({ id: +req.params.listId, accountId: (await accountGetter(res)).id })
if (!watchedWordsList) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Unknown watched words list id for this account'
})
}
res.locals.watchedWordsList = watchedWordsList
return next()
}
]
}
function buildUpdateOrAddValidators ({ optional }: { optional: boolean }) {
const makeOptionalIfNeeded = (chain: ValidationChain) => {
if (optional) return chain.optional()
return chain
}
return [
makeOptionalIfNeeded(body('listName'))
.trim()
.custom(isWatchedWordListNameValid).withMessage(
`Should have a list name between ` +
`${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.max} characters long`
),
makeOptionalIfNeeded(body('words'))
.custom(areWatchedWordsValid)
.withMessage(
`Should have an array of up to ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORDS.max} words between ` +
`${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.max} characters each`
)
]
}
export function addWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return [
...buildUpdateOrAddValidators({ optional: false }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const listName = req.body.listName
if (!await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, res })) return
return next()
}
]
}
export function updateWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return [
...buildUpdateOrAddValidators({ optional: true }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const currentList = res.locals.watchedWordsList
const listName = req.body.listName
if (listName && !await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, currentList, res })) return
return next()
}
]
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkListNameIsUnique (options: {
accountId: number
listName: string
res: express.Response
currentList?: MWatchedWordsList
}) {
const { accountId, listName, currentList, res } = options
const existing = await WatchedWordsListModel.loadByListName({ accountId, listName })
if (existing && (!currentList || currentList.id !== existing.id)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Watched words list with name ${listName} already exists`
})
return false
}
return true
}

View File

@@ -0,0 +1,37 @@
import express from 'express'
import { query } from 'express-validator'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isWebfingerLocalResourceValid } from '../../helpers/custom-validators/webfinger.js'
import { getHostWithPort } from '../../helpers/express-utils.js'
import { ActorModel } from '../../models/actor/actor.js'
import { areValidationErrors } from './shared/index.js'
const webfingerValidator = [
query('resource')
.custom(isWebfingerLocalResourceValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
// Remove 'acct:' from the beginning of the string
const handle = getHostWithPort(req.query.resource.substr(5))
const [ name ] = handle.split('@')
const actor = await ActorModel.loadLocalUrlByName(name)
if (!actor) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Actor not found'
})
}
res.locals.actorUrl = actor
return next()
}
]
// ---------------------------------------------------------------------------
export {
webfingerValidator
}