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