From e364e31e25bd1d4b8d801c845a96d6be708f0a18 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 19 Jan 2023 09:27:16 +0100 Subject: [PATCH] Implement signup approval in server --- config/default.yaml | 6 + config/production.yaml.example | 6 + config/test.yaml | 1 + server/controllers/api/config.ts | 1 + .../api/users/email-verification.ts | 72 +++++ server/controllers/api/users/index.ts | 99 +------ server/controllers/api/users/registrations.ts | 236 ++++++++++++++++ .../custom-validators/user-registration.ts | 25 ++ server/initializers/checker-after-init.ts | 5 + server/initializers/checker-before-init.ts | 2 +- server/initializers/config.ts | 1 + server/initializers/constants.ts | 20 +- server/initializers/database.ts | 6 +- .../migrations/0750-user-registration.ts | 58 ++++ server/lib/auth/oauth.ts | 29 +- server/lib/emailer.ts | 54 +++- server/lib/emails/common/base.pug | 12 +- .../html.pug | 10 + .../html.pug | 9 + .../emails/user-registration-request/html.pug | 9 + server/lib/emails/verify-email/html.pug | 26 +- server/lib/notifier/notifier.ts | 19 +- ... => direct-registration-for-moderators.ts} | 4 +- server/lib/notifier/shared/instance/index.ts | 3 +- .../registration-request-for-moderators.ts | 48 ++++ server/lib/redis.ts | 30 +- server/lib/server-config-manager.ts | 12 +- server/lib/signup.ts | 15 +- server/lib/user.ts | 38 ++- server/middlewares/validators/config.ts | 1 + server/middlewares/validators/index.ts | 2 + .../validators/shared/user-registrations.ts | 60 ++++ server/middlewares/validators/shared/users.ts | 4 +- server/middlewares/validators/sort.ts | 95 +++---- .../validators/user-email-verification.ts | 94 +++++++ .../validators/user-registrations.ts | 203 ++++++++++++++ server/middlewares/validators/users.ts | 151 +--------- .../user-notitication-list-query-builder.ts | 130 ++++----- server/models/user/user-notification.ts | 26 ++ server/models/user/user-registration.ts | 259 ++++++++++++++++++ server/models/user/user.ts | 17 +- server/types/express.d.ts | 2 + server/types/models/user/index.ts | 1 + server/types/models/user/user-notification.ts | 9 +- server/types/models/user/user-registration.ts | 15 + shared/core-utils/users/user-role.ts | 3 +- .../plugins/server/server-hook.model.ts | 7 + shared/models/server/custom-config.model.ts | 1 + shared/models/server/server-config.model.ts | 1 + .../models/server/server-error-code.enum.ts | 10 +- shared/models/users/index.ts | 2 +- shared/models/users/registration/index.ts | 5 + .../{ => registration}/user-register.model.ts | 0 .../user-registration-request.model.ts | 5 + .../user-registration-state.model.ts | 5 + .../user-registration-update-state.model.ts | 3 + .../registration/user-registration.model.ts | 29 ++ .../models/users/user-notification.model.ts | 9 +- shared/models/users/user-right.enum.ts | 4 +- 59 files changed, 1561 insertions(+), 448 deletions(-) create mode 100644 server/controllers/api/users/email-verification.ts create mode 100644 server/controllers/api/users/registrations.ts create mode 100644 server/helpers/custom-validators/user-registration.ts create mode 100644 server/initializers/migrations/0750-user-registration.ts create mode 100644 server/lib/emails/user-registration-request-accepted/html.pug create mode 100644 server/lib/emails/user-registration-request-rejected/html.pug create mode 100644 server/lib/emails/user-registration-request/html.pug rename server/lib/notifier/shared/instance/{registration-for-moderators.ts => direct-registration-for-moderators.ts} (90%) create mode 100644 server/lib/notifier/shared/instance/registration-request-for-moderators.ts create mode 100644 server/middlewares/validators/shared/user-registrations.ts create mode 100644 server/middlewares/validators/user-email-verification.ts create mode 100644 server/middlewares/validators/user-registrations.ts create mode 100644 server/models/user/user-registration.ts create mode 100644 server/types/models/user/user-registration.ts create mode 100644 shared/models/users/registration/index.ts rename shared/models/users/{ => registration}/user-register.model.ts (100%) create mode 100644 shared/models/users/registration/user-registration-request.model.ts create mode 100644 shared/models/users/registration/user-registration-state.model.ts create mode 100644 shared/models/users/registration/user-registration-update-state.model.ts create mode 100644 shared/models/users/registration/user-registration.model.ts diff --git a/config/default.yaml b/config/default.yaml index b2c418a0a..37059e9e0 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -382,9 +382,15 @@ contact_form: signup: enabled: false + limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited + minimum_age: 16 # Used to configure the signup form + + # Users fill a form to register so moderators can accept/reject the registration + requires_approval: true requires_email_verification: false + filters: cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist whitelist: [] diff --git a/config/production.yaml.example b/config/production.yaml.example index 36fa70417..906fb7e1f 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -392,9 +392,15 @@ contact_form: signup: enabled: false + limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited + minimum_age: 16 # Used to configure the signup form + + # Users fill a form to register so moderators can accept/reject the registration + requires_approval: true requires_email_verification: false + filters: cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist whitelist: [] diff --git a/config/test.yaml b/config/test.yaml index 878d68cb9..94d74ffa5 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -74,6 +74,7 @@ cache: signup: enabled: true + requires_approval: false requires_email_verification: false transcoding: diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index f0fb43071..86434f382 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -193,6 +193,7 @@ function customConfig (): CustomConfig { signup: { enabled: CONFIG.SIGNUP.ENABLED, limit: CONFIG.SIGNUP.LIMIT, + requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL, requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION, minimumAge: CONFIG.SIGNUP.MINIMUM_AGE }, diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts new file mode 100644 index 000000000..230aaa9af --- /dev/null +++ b/server/controllers/api/users/email-verification.ts @@ -0,0 +1,72 @@ +import express from 'express' +import { HttpStatusCode } from '@shared/models' +import { CONFIG } from '../../../initializers/config' +import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' +import { asyncMiddleware, buildRateLimiter } from '../../../middlewares' +import { + registrationVerifyEmailValidator, + usersAskSendVerifyEmailValidator, + usersVerifyEmailValidator +} from '../../../middlewares/validators' + +const askSendEmailLimiter = buildRateLimiter({ + windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, + max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX +}) + +const emailVerificationRouter = express.Router() + +emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ], + askSendEmailLimiter, + asyncMiddleware(usersAskSendVerifyEmailValidator), + asyncMiddleware(reSendVerifyUserEmail) +) + +emailVerificationRouter.post('/:id/verify-email', + asyncMiddleware(usersVerifyEmailValidator), + asyncMiddleware(verifyUserEmail) +) + +emailVerificationRouter.post('/registrations/:registrationId/verify-email', + asyncMiddleware(registrationVerifyEmailValidator), + asyncMiddleware(verifyRegistrationEmail) +) + +// --------------------------------------------------------------------------- + +export { + emailVerificationRouter +} + +async function reSendVerifyUserEmail (req: express.Request, res: express.Response) { + const user = res.locals.user + const registration = res.locals.userRegistration + + if (user) await sendVerifyUserEmail(user) + else if (registration) await sendVerifyRegistrationEmail(registration) + + return res.status(HttpStatusCode.NO_CONTENT_204).end() +} + +async function verifyUserEmail (req: express.Request, res: express.Response) { + const user = res.locals.user + user.emailVerified = true + + if (req.body.isPendingEmail === true) { + user.email = user.pendingEmail + user.pendingEmail = null + } + + await user.save() + + return res.status(HttpStatusCode.NO_CONTENT_204).end() +} + +async function verifyRegistrationEmail (req: express.Request, res: express.Response) { + const registration = res.locals.userRegistration + registration.emailVerified = true + + await registration.save() + + return res.status(HttpStatusCode.NO_CONTENT_204).end() +} diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index a8677a1d3..5a5a12e82 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -4,26 +4,21 @@ import { Hooks } from '@server/lib/plugins/hooks' import { OAuthTokenModel } from '@server/models/oauth/oauth-token' import { MUserAccountDefault } from '@server/types/models' import { pick } from '@shared/core-utils' -import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' +import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models' import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' import { logger } from '../../../helpers/logger' import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' -import { CONFIG } from '../../../initializers/config' import { WEBSERVER } from '../../../initializers/constants' import { sequelizeTypescript } from '../../../initializers/database' import { Emailer } from '../../../lib/emailer' -import { Notifier } from '../../../lib/notifier' import { Redis } from '../../../lib/redis' -import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' +import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user' import { adminUsersSortValidator, asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, - buildRateLimiter, ensureUserHasRight, - ensureUserRegistrationAllowed, - ensureUserRegistrationAllowedForIP, paginationValidator, setDefaultPagination, setDefaultSort, @@ -31,19 +26,17 @@ import { usersAddValidator, usersGetValidator, usersListValidator, - usersRegisterValidator, usersRemoveValidator, usersUpdateValidator } from '../../../middlewares' import { ensureCanModerateUser, usersAskResetPasswordValidator, - usersAskSendVerifyEmailValidator, usersBlockingValidator, - usersResetPasswordValidator, - usersVerifyEmailValidator + usersResetPasswordValidator } from '../../../middlewares/validators' import { UserModel } from '../../../models/user/user' +import { emailVerificationRouter } from './email-verification' import { meRouter } from './me' import { myAbusesRouter } from './my-abuses' import { myBlocklistRouter } from './my-blocklist' @@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history' import { myNotificationsRouter } from './my-notifications' import { mySubscriptionsRouter } from './my-subscriptions' import { myVideoPlaylistsRouter } from './my-video-playlists' +import { registrationsRouter } from './registrations' import { twoFactorRouter } from './two-factor' const auditLogger = auditLoggerFactory('users') -const signupRateLimiter = buildRateLimiter({ - windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, - max: CONFIG.RATES_LIMIT.SIGNUP.MAX, - skipFailedRequests: true -}) - -const askSendEmailLimiter = buildRateLimiter({ - windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, - max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX -}) - const usersRouter = express.Router() +usersRouter.use('/', emailVerificationRouter) +usersRouter.use('/', registrationsRouter) usersRouter.use('/', twoFactorRouter) usersRouter.use('/', tokensRouter) usersRouter.use('/', myNotificationsRouter) @@ -122,14 +107,6 @@ usersRouter.post('/', asyncRetryTransactionMiddleware(createUser) ) -usersRouter.post('/register', - signupRateLimiter, - asyncMiddleware(ensureUserRegistrationAllowed), - ensureUserRegistrationAllowedForIP, - asyncMiddleware(usersRegisterValidator), - asyncRetryTransactionMiddleware(registerUser) -) - usersRouter.put('/:id', authenticate, ensureUserHasRight(UserRight.MANAGE_USERS), @@ -156,17 +133,6 @@ usersRouter.post('/:id/reset-password', asyncMiddleware(resetUserPassword) ) -usersRouter.post('/ask-send-verify-email', - askSendEmailLimiter, - asyncMiddleware(usersAskSendVerifyEmailValidator), - asyncMiddleware(reSendVerifyUserEmail) -) - -usersRouter.post('/:id/verify-email', - asyncMiddleware(usersVerifyEmailValidator), - asyncMiddleware(verifyUserEmail) -) - // --------------------------------------------------------------------------- export { @@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) { }) } -async function registerUser (req: express.Request, res: express.Response) { - const body: UserRegister = req.body - - const userToCreate = buildUser({ - ...pick(body, [ 'username', 'password', 'email' ]), - - emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null - }) - - const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({ - userToCreate, - userDisplayName: body.displayName || undefined, - channelNames: body.channel - }) - - auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) - logger.info('User %s with its channel and account registered.', body.username) - - if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { - await sendVerifyUserEmail(user) - } - - Notifier.Instance.notifyOnNewUserRegistration(user) - - Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res }) - - return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end() -} - async function unblockUser (req: express.Request, res: express.Response) { const user = res.locals.user @@ -360,28 +297,6 @@ async function resetUserPassword (req: express.Request, res: express.Response) { return res.status(HttpStatusCode.NO_CONTENT_204).end() } -async function reSendVerifyUserEmail (req: express.Request, res: express.Response) { - const user = res.locals.user - - await sendVerifyUserEmail(user) - - return res.status(HttpStatusCode.NO_CONTENT_204).end() -} - -async function verifyUserEmail (req: express.Request, res: express.Response) { - const user = res.locals.user - user.emailVerified = true - - if (req.body.isPendingEmail === true) { - user.email = user.pendingEmail - user.pendingEmail = null - } - - await user.save() - - return res.status(HttpStatusCode.NO_CONTENT_204).end() -} - async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) { const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts new file mode 100644 index 000000000..3d4e0aa18 --- /dev/null +++ b/server/controllers/api/users/registrations.ts @@ -0,0 +1,236 @@ +import express from 'express' +import { Emailer } from '@server/lib/emailer' +import { Hooks } from '@server/lib/plugins/hooks' +import { UserRegistrationModel } from '@server/models/user/user-registration' +import { pick } from '@shared/core-utils' +import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState, UserRight } from '@shared/models' +import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' +import { logger } from '../../../helpers/logger' +import { CONFIG } from '../../../initializers/config' +import { Notifier } from '../../../lib/notifier' +import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' +import { + acceptOrRejectRegistrationValidator, + asyncMiddleware, + asyncRetryTransactionMiddleware, + authenticate, + buildRateLimiter, + ensureUserHasRight, + ensureUserRegistrationAllowedFactory, + ensureUserRegistrationAllowedForIP, + getRegistrationValidator, + listRegistrationsValidator, + paginationValidator, + setDefaultPagination, + setDefaultSort, + userRegistrationsSortValidator, + usersDirectRegistrationValidator, + usersRequestRegistrationValidator +} from '../../../middlewares' + +const auditLogger = auditLoggerFactory('users') + +const registrationRateLimiter = buildRateLimiter({ + windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, + max: CONFIG.RATES_LIMIT.SIGNUP.MAX, + skipFailedRequests: true +}) + +const registrationsRouter = express.Router() + +registrationsRouter.post('/registrations/request', + registrationRateLimiter, + asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')), + ensureUserRegistrationAllowedForIP, + asyncMiddleware(usersRequestRegistrationValidator), + asyncRetryTransactionMiddleware(requestRegistration) +) + +registrationsRouter.post('/registrations/:registrationId/accept', + authenticate, + ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), + asyncMiddleware(acceptOrRejectRegistrationValidator), + asyncRetryTransactionMiddleware(acceptRegistration) +) +registrationsRouter.post('/registrations/:registrationId/reject', + authenticate, + ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), + asyncMiddleware(acceptOrRejectRegistrationValidator), + asyncRetryTransactionMiddleware(rejectRegistration) +) + +registrationsRouter.delete('/registrations/:registrationId', + authenticate, + ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), + asyncMiddleware(getRegistrationValidator), + asyncRetryTransactionMiddleware(deleteRegistration) +) + +registrationsRouter.get('/registrations', + authenticate, + ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), + paginationValidator, + userRegistrationsSortValidator, + setDefaultSort, + setDefaultPagination, + listRegistrationsValidator, + asyncMiddleware(listRegistrations) +) + +registrationsRouter.post('/register', + registrationRateLimiter, + asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')), + ensureUserRegistrationAllowedForIP, + asyncMiddleware(usersDirectRegistrationValidator), + asyncRetryTransactionMiddleware(registerUser) +) + +// --------------------------------------------------------------------------- + +export { + registrationsRouter +} + +// --------------------------------------------------------------------------- + +async function requestRegistration (req: express.Request, res: express.Response) { + const body: UserRegistrationRequest = req.body + + const registration = new UserRegistrationModel({ + ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]), + + accountDisplayName: body.displayName, + channelDisplayName: body.channel?.displayName, + channelHandle: body.channel?.name, + + state: UserRegistrationState.PENDING, + + emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null + }) + + await registration.save() + + if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { + await sendVerifyRegistrationEmail(registration) + } + + Notifier.Instance.notifyOnNewRegistrationRequest(registration) + + Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res }) + + return res.json(registration.toFormattedJSON()) +} + +// --------------------------------------------------------------------------- + +async function acceptRegistration (req: express.Request, res: express.Response) { + const registration = res.locals.userRegistration + + const userToCreate = buildUser({ + username: registration.username, + password: registration.password, + email: registration.email, + emailVerified: registration.emailVerified + }) + // We already encrypted password in registration model + userToCreate.skipPasswordEncryption = true + + // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval + + const { user } = await createUserAccountAndChannelAndPlaylist({ + userToCreate, + userDisplayName: registration.accountDisplayName, + channelNames: registration.channelHandle && registration.channelDisplayName + ? { + name: registration.channelHandle, + displayName: registration.channelDisplayName + } + : undefined + }) + + registration.userId = user.id + registration.state = UserRegistrationState.ACCEPTED + registration.moderationResponse = req.body.moderationResponse + + await registration.save() + + logger.info('Registration of %s accepted', registration.username) + + Emailer.Instance.addUserRegistrationRequestProcessedJob(registration) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function rejectRegistration (req: express.Request, res: express.Response) { + const registration = res.locals.userRegistration + + registration.state = UserRegistrationState.REJECTED + registration.moderationResponse = req.body.moderationResponse + + await registration.save() + + Emailer.Instance.addUserRegistrationRequestProcessedJob(registration) + + logger.info('Registration of %s rejected', registration.username) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +// --------------------------------------------------------------------------- + +async function deleteRegistration (req: express.Request, res: express.Response) { + const registration = res.locals.userRegistration + + await registration.destroy() + + logger.info('Registration of %s deleted', registration.username) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +// --------------------------------------------------------------------------- + +async function listRegistrations (req: express.Request, res: express.Response) { + const resultList = await UserRegistrationModel.listForApi({ + start: req.query.start, + count: req.query.count, + sort: req.query.sort, + search: req.query.search + }) + + return res.json({ + total: resultList.total, + data: resultList.data.map(d => d.toFormattedJSON()) + }) +} + +// --------------------------------------------------------------------------- + +async function registerUser (req: express.Request, res: express.Response) { + const body: UserRegister = req.body + + const userToCreate = buildUser({ + ...pick(body, [ 'username', 'password', 'email' ]), + + emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null + }) + + const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({ + userToCreate, + userDisplayName: body.displayName || undefined, + channelNames: body.channel + }) + + auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) + logger.info('User %s with its channel and account registered.', body.username) + + if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { + await sendVerifyUserEmail(user) + } + + Notifier.Instance.notifyOnNewDirectRegistration(user) + + Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res }) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/helpers/custom-validators/user-registration.ts b/server/helpers/custom-validators/user-registration.ts new file mode 100644 index 000000000..9da0bb08a --- /dev/null +++ b/server/helpers/custom-validators/user-registration.ts @@ -0,0 +1,25 @@ +import validator from 'validator' +import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants' +import { exists } from './misc' + +const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS + +function isRegistrationStateValid (value: string) { + return exists(value) && USER_REGISTRATION_STATES[value] !== undefined +} + +function isRegistrationModerationResponseValid (value: string) { + return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE) +} + +function isRegistrationReasonValid (value: string) { + return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE) +} + +// --------------------------------------------------------------------------- + +export { + isRegistrationStateValid, + isRegistrationModerationResponseValid, + isRegistrationReasonValid +} diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index dc46b5126..247bc2ad5 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -116,6 +116,11 @@ function checkEmailConfig () { throw new Error('Emailer is disabled but you require signup email verification.') } + if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_APPROVAL) { + // eslint-disable-next-line max-len + logger.warn('Emailer is disabled but signup approval is enabled: PeerTube will not be able to send an email to the user upon acceptance/rejection of the registration request') + } + if (CONFIG.CONTACT_FORM.ENABLED) { logger.warn('Emailer is disabled so the contact form will not work.') } diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 57852241c..8b4d49180 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -28,7 +28,7 @@ function checkMissedConfig () { 'csp.enabled', 'csp.report_only', 'csp.report_uri', 'security.frameguard.enabled', 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled', - 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age', + 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 28aaf36a9..9685e7bfc 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -305,6 +305,7 @@ const CONFIG = { }, SIGNUP: { get ENABLED () { return config.get('signup.enabled') }, + get REQUIRES_APPROVAL () { return config.get('signup.requires_approval') }, get LIMIT () { return config.get('signup.limit') }, get REQUIRES_EMAIL_VERIFICATION () { return config.get('signup.requires_email_verification') }, get MINIMUM_AGE () { return config.get('signup.minimum_age') }, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 0dab524d9..2ef3da2e7 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -6,6 +6,7 @@ import { randomInt, root } from '@shared/core-utils' import { AbuseState, JobType, + UserRegistrationState, VideoChannelSyncState, VideoImportState, VideoPrivacy, @@ -25,7 +26,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 745 +const LAST_MIGRATION_VERSION = 750 // --------------------------------------------------------------------------- @@ -78,6 +79,8 @@ const SORTABLE_COLUMNS = { ACCOUNT_FOLLOWERS: [ 'createdAt' ], CHANNEL_FOLLOWERS: [ 'createdAt' ], + USER_REGISTRATIONS: [ 'createdAt', 'state' ], + VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], // Don't forget to update peertube-search-index with the same values @@ -290,6 +293,10 @@ const CONSTRAINTS_FIELDS = { ABUSE_MESSAGES: { MESSAGE: { min: 2, max: 3000 } // Length }, + USER_REGISTRATIONS: { + REASON_MESSAGE: { min: 2, max: 3000 }, // Length + MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length + }, VIDEO_BLACKLIST: { REASON: { min: 2, max: 300 } // Length }, @@ -516,6 +523,12 @@ const ABUSE_STATES: { [ id in AbuseState ]: string } = { [AbuseState.ACCEPTED]: 'Accepted' } +const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = { + [UserRegistrationState.PENDING]: 'Pending', + [UserRegistrationState.REJECTED]: 'Rejected', + [UserRegistrationState.ACCEPTED]: 'Accepted' +} + const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { [VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', @@ -660,7 +673,7 @@ const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes -const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes +const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { DO_NOT_LIST: 'do_not_list', @@ -1069,13 +1082,14 @@ export { VIDEO_TRANSCODING_FPS, FFMPEG_NICE, ABUSE_STATES, + USER_REGISTRATION_STATES, LRU_CACHE, REQUEST_TIMEOUTS, MAX_LOCAL_VIEWER_WATCH_SECTIONS, USER_PASSWORD_RESET_LIFETIME, USER_PASSWORD_CREATE_LIFETIME, MEMOIZE_TTL, - USER_EMAIL_VERIFY_LIFETIME, + EMAIL_VERIFY_LIFETIME, OVERVIEWS, SCHEDULER_INTERVALS_MS, REPEAT_JOBS, diff --git a/server/initializers/database.ts b/server/initializers/database.ts index f55f40df0..96145f489 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -5,7 +5,9 @@ import { TrackerModel } from '@server/models/server/tracker' import { VideoTrackerModel } from '@server/models/server/video-tracker' import { UserModel } from '@server/models/user/user' import { UserNotificationModel } from '@server/models/user/user-notification' +import { UserRegistrationModel } from '@server/models/user/user-registration' import { UserVideoHistoryModel } from '@server/models/user/user-video-history' +import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' import { VideoJobInfoModel } from '@server/models/video/video-job-info' import { VideoLiveSessionModel } from '@server/models/video/video-live-session' import { VideoSourceModel } from '@server/models/video/video-source' @@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag' import { VideoViewModel } from '../models/view/video-view' import { CONFIG } from './config' -import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -155,7 +156,8 @@ async function initDatabaseModels (silent: boolean) { PluginModel, ActorCustomPageModel, VideoJobInfoModel, - VideoChannelSyncModel + VideoChannelSyncModel, + UserRegistrationModel ]) // Check extensions exist in the database diff --git a/server/initializers/migrations/0750-user-registration.ts b/server/initializers/migrations/0750-user-registration.ts new file mode 100644 index 000000000..15bbfd3fd --- /dev/null +++ b/server/initializers/migrations/0750-user-registration.ts @@ -0,0 +1,58 @@ + +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + { + const query = ` + CREATE TABLE IF NOT EXISTS "userRegistration" ( + "id" serial, + "state" integer NOT NULL, + "registrationReason" text NOT NULL, + "moderationResponse" text, + "password" varchar(255), + "username" varchar(255) NOT NULL, + "email" varchar(400) NOT NULL, + "emailVerified" boolean, + "accountDisplayName" varchar(255), + "channelHandle" varchar(255), + "channelDisplayName" varchar(255), + "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + PRIMARY KEY ("id") + ); + ` + await utils.sequelize.query(query, { transaction: utils.transaction }) + } + + { + await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: true, + references: { + model: 'userRegistration', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, { transaction: utils.transaction }) + } +} + +async function down (utils: { + queryInterface: Sequelize.QueryInterface + transaction: Sequelize.Transaction +}) { + await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction }) +} + +export { + up, + down +} diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index 2905c79a2..887c4f7c9 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -11,20 +11,31 @@ import OAuth2Server, { import { randomBytesPromise } from '@server/helpers/core-utils' import { isOTPValid } from '@server/helpers/otp' import { CONFIG } from '@server/initializers/config' +import { UserRegistrationModel } from '@server/models/user/user-registration' import { MOAuthClient } from '@server/types/models' import { sha1 } from '@shared/extra-utils' -import { HttpStatusCode } from '@shared/models' +import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models' import { OTP } from '../../initializers/constants' import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' class MissingTwoFactorError extends Error { code = HttpStatusCode.UNAUTHORIZED_401 - name = 'missing_two_factor' + name = ServerErrorCode.MISSING_TWO_FACTOR } class InvalidTwoFactorError extends Error { code = HttpStatusCode.BAD_REQUEST_400 - name = 'invalid_two_factor' + name = ServerErrorCode.INVALID_TWO_FACTOR +} + +class RegistrationWaitingForApproval extends Error { + code = HttpStatusCode.BAD_REQUEST_400 + name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL +} + +class RegistrationApprovalRejected extends Error { + code = HttpStatusCode.BAD_REQUEST_400 + name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED } /** @@ -128,7 +139,17 @@ async function handlePasswordGrant (options: { } const user = await getUser(request.body.username, request.body.password, bypassLogin) - if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') + if (!user) { + const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username) + + if (registration?.state === UserRegistrationState.REJECTED) { + throw new RegistrationApprovalRejected('Registration approval for this account has been rejected') + } else if (registration?.state === UserRegistrationState.PENDING) { + throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval') + } + + throw new InvalidGrantError('Invalid grant: user credentials are invalid') + } if (user.otpSecret) { if (!request.headers[OTP.HEADER_NAME]) { diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index 39b662eb2..f5c3e4745 100644 --- a/server/lib/emailer.ts +++ b/server/lib/emailer.ts @@ -3,13 +3,13 @@ import { merge } from 'lodash' import { createTransport, Transporter } from 'nodemailer' import { join } from 'path' import { arrayify, root } from '@shared/core-utils' -import { EmailPayload } from '@shared/models' +import { EmailPayload, UserRegistrationState } from '@shared/models' import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' import { isTestOrDevInstance } from '../helpers/core-utils' import { bunyanLogger, logger } from '../helpers/logger' import { CONFIG, isEmailEnabled } from '../initializers/config' import { WEBSERVER } from '../initializers/constants' -import { MUser } from '../types/models' +import { MRegistration, MUser } from '../types/models' import { JobQueue } from './job-queue' const Email = require('email-templates') @@ -62,7 +62,9 @@ class Emailer { subject: 'Reset your account password', locals: { username, - resetPasswordUrl + resetPasswordUrl, + + hideNotificationPreferencesLink: true } } @@ -76,21 +78,33 @@ class Emailer { subject: 'Create your account password', locals: { username, - createPasswordUrl + createPasswordUrl, + + hideNotificationPreferencesLink: true } } return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload }) } - addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) { + addVerifyEmailJob (options: { + username: string + isRegistrationRequest: boolean + to: string + verifyEmailUrl: string + }) { + const { username, isRegistrationRequest, to, verifyEmailUrl } = options + const emailPayload: EmailPayload = { template: 'verify-email', to: [ to ], subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`, locals: { username, - verifyEmailUrl + verifyEmailUrl, + isRegistrationRequest, + + hideNotificationPreferencesLink: true } } @@ -123,7 +137,33 @@ class Emailer { body, // There are not notification preferences for the contact form - hideNotificationPreferences: true + hideNotificationPreferencesLink: true + } + } + + return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload }) + } + + addUserRegistrationRequestProcessedJob (registration: MRegistration) { + let template: string + let subject: string + if (registration.state === UserRegistrationState.ACCEPTED) { + template = 'user-registration-request-accepted' + subject = `Your registration request for ${registration.username} has been accepted` + } else { + template = 'user-registration-request-rejected' + subject = `Your registration request for ${registration.username} has been rejected` + } + + const to = registration.email + const emailPayload: EmailPayload = { + to: [ to ], + template, + subject, + locals: { + username: registration.username, + moderationResponse: registration.moderationResponse, + loginLink: WEBSERVER.URL + '/login' } } diff --git a/server/lib/emails/common/base.pug b/server/lib/emails/common/base.pug index 6da5648e4..41e94564d 100644 --- a/server/lib/emails/common/base.pug +++ b/server/lib/emails/common/base.pug @@ -222,19 +222,9 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;') br //- Clear Spacer : END - //- 1 Column Text : BEGIN - if username - tr - td(style='background-color: #cccccc;') - table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%') - tr - td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;') - p(style='margin: 0;') - | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}. - //- 1 Column Text : END //- Email Body : END //- Email Footer : BEGIN - unless hideNotificationPreferences + unless hideNotificationPreferencesLink table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') tr td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') diff --git a/server/lib/emails/user-registration-request-accepted/html.pug b/server/lib/emails/user-registration-request-accepted/html.pug new file mode 100644 index 000000000..7a52c3fe1 --- /dev/null +++ b/server/lib/emails/user-registration-request-accepted/html.pug @@ -0,0 +1,10 @@ +extends ../common/greetings + +block title + | Congratulation #{username}, your registration request has been accepted! + +block content + p Your registration request has been accepted. + p Moderators sent you the following message: + blockquote(style='white-space: pre-wrap') #{moderationResponse} + p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}] diff --git a/server/lib/emails/user-registration-request-rejected/html.pug b/server/lib/emails/user-registration-request-rejected/html.pug new file mode 100644 index 000000000..ec0aa8dfe --- /dev/null +++ b/server/lib/emails/user-registration-request-rejected/html.pug @@ -0,0 +1,9 @@ +extends ../common/greetings + +block title + | Registration request of your account #{username} has rejected + +block content + p Your registration request has been rejected. + p Moderators sent you the following message: + blockquote(style='white-space: pre-wrap') #{moderationResponse} diff --git a/server/lib/emails/user-registration-request/html.pug b/server/lib/emails/user-registration-request/html.pug new file mode 100644 index 000000000..64898f3f2 --- /dev/null +++ b/server/lib/emails/user-registration-request/html.pug @@ -0,0 +1,9 @@ +extends ../common/greetings + +block title + | A new user wants to register + +block content + p User #{registration.username} wants to register on your PeerTube instance with the following reason: + blockquote(style='white-space: pre-wrap') #{registration.registrationReason} + p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration]. diff --git a/server/lib/emails/verify-email/html.pug b/server/lib/emails/verify-email/html.pug index be9dde21b..19ef65f75 100644 --- a/server/lib/emails/verify-email/html.pug +++ b/server/lib/emails/verify-email/html.pug @@ -1,17 +1,19 @@ extends ../common/greetings block title - | Account verification + | Email verification block content - p Welcome to #{instanceName}! - p. - You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}]. - Your username there is: #{username}. - p. - To start using your account you must verify your email first! - Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you. - p. - If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}] - p. - If you are not the person who initiated this request, please ignore this email. + if isRegistrationRequest + p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}]. + else + p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}]. + + if isRegistrationRequest + p To complete your registration request you must verify your email first! + else + p To start using your account you must verify your email first! + + p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you. + p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}] + p If you are not the person who initiated this request, please ignore this email. diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts index 66cfc31c4..920c55df0 100644 --- a/server/lib/notifier/notifier.ts +++ b/server/lib/notifier/notifier.ts @@ -1,4 +1,4 @@ -import { MUser, MUserDefault } from '@server/types/models/user' +import { MRegistration, MUser, MUserDefault } from '@server/types/models/user' import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' import { UserNotificationSettingValue } from '../../../shared/models/users' import { logger } from '../../helpers/logger' @@ -13,6 +13,7 @@ import { AbuseStateChangeForReporter, AutoFollowForInstance, CommentMention, + DirectRegistrationForModerators, FollowForInstance, FollowForUser, ImportFinishedForOwner, @@ -30,7 +31,7 @@ import { OwnedPublicationAfterAutoUnblacklist, OwnedPublicationAfterScheduleUpdate, OwnedPublicationAfterTranscoding, - RegistrationForModerators, + RegistrationRequestForModerators, StudioEditionFinishedForOwner, UnblacklistForOwner } from './shared' @@ -47,7 +48,8 @@ class Notifier { newBlacklist: [ NewBlacklistForOwner ], unblacklist: [ UnblacklistForOwner ], importFinished: [ ImportFinishedForOwner ], - userRegistration: [ RegistrationForModerators ], + directRegistration: [ DirectRegistrationForModerators ], + registrationRequest: [ RegistrationRequestForModerators ], userFollow: [ FollowForUser ], instanceFollow: [ FollowForInstance ], autoInstanceFollow: [ AutoFollowForInstance ], @@ -138,13 +140,20 @@ class Notifier { }) } - notifyOnNewUserRegistration (user: MUserDefault): void { - const models = this.notificationModels.userRegistration + notifyOnNewDirectRegistration (user: MUserDefault): void { + const models = this.notificationModels.directRegistration this.sendNotifications(models, user) .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err })) } + notifyOnNewRegistrationRequest (registration: MRegistration): void { + const models = this.notificationModels.registrationRequest + + this.sendNotifications(models, registration) + .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err })) + } + notifyOfNewUserFollow (actorFollow: MActorFollowFull): void { const models = this.notificationModels.userFollow diff --git a/server/lib/notifier/shared/instance/registration-for-moderators.ts b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts similarity index 90% rename from server/lib/notifier/shared/instance/registration-for-moderators.ts rename to server/lib/notifier/shared/instance/direct-registration-for-moderators.ts index e92467424..5044f2068 100644 --- a/server/lib/notifier/shared/instance/registration-for-moderators.ts +++ b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts @@ -6,7 +6,7 @@ import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi import { UserNotificationType, UserRight } from '@shared/models' import { AbstractNotification } from '../common/abstract-notification' -export class RegistrationForModerators extends AbstractNotification { +export class DirectRegistrationForModerators extends AbstractNotification { private moderators: MUserDefault[] async prepare () { @@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification { + private moderators: MUserDefault[] + + async prepare () { + this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS) + } + + log () { + logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username) + } + + getSetting (user: MUserWithNotificationSetting) { + return user.NotificationSetting.newUserRegistration + } + + getTargetUsers () { + return this.moderators + } + + createNotification (user: MUserWithNotificationSetting) { + const notification = UserNotificationModel.build({ + type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST, + userId: user.id, + userRegistrationId: this.payload.id + }) + notification.UserRegistration = this.payload + + return notification + } + + createEmail (to: string) { + return { + template: 'user-registration-request', + to, + subject: `A new user wants to register: ${this.payload.username}`, + locals: { + registration: this.payload + } + } + } +} diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 451ddd0b6..3706d2228 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -9,7 +9,7 @@ import { CONTACT_FORM_LIFETIME, RESUMABLE_UPLOAD_SESSION_LIFETIME, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, - USER_EMAIL_VERIFY_LIFETIME, + EMAIL_VERIFY_LIFETIME, USER_PASSWORD_CREATE_LIFETIME, USER_PASSWORD_RESET_LIFETIME, VIEW_LIFETIME, @@ -124,16 +124,28 @@ class Redis { /* ************ Email verification ************ */ - async setVerifyEmailVerificationString (userId: number) { + async setUserVerifyEmailVerificationString (userId: number) { const generatedString = await generateRandomString(32) - await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) + await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME) return generatedString } - async getVerifyEmailLink (userId: number) { - return this.getValue(this.generateVerifyEmailKey(userId)) + async getUserVerifyEmailLink (userId: number) { + return this.getValue(this.generateUserVerifyEmailKey(userId)) + } + + async setRegistrationVerifyEmailVerificationString (registrationId: number) { + const generatedString = await generateRandomString(32) + + await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME) + + return generatedString + } + + async getRegistrationVerifyEmailLink (registrationId: number) { + return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId)) } /* ************ Contact form per IP ************ */ @@ -346,8 +358,12 @@ class Redis { return 'two-factor-request-' + userId + '-' + token } - private generateVerifyEmailKey (userId: number) { - return 'verify-email-' + userId + private generateUserVerifyEmailKey (userId: number) { + return 'verify-email-user-' + userId + } + + private generateRegistrationVerifyEmailKey (registrationId: number) { + return 'verify-email-registration-' + registrationId } private generateIPViewKey (ip: string, videoUUID: string) { diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts index 78a9546ae..e87e2854f 100644 --- a/server/lib/server-config-manager.ts +++ b/server/lib/server-config-manager.ts @@ -261,10 +261,17 @@ class ServerConfigManager { async getServerConfig (ip?: string): Promise { const { allowed } = await Hooks.wrapPromiseFun( isSignupAllowed, + { - ip + ip, + signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL + ? 'request-registration' + : 'direct-registration' }, - 'filter:api.user.signup.allowed.result' + + CONFIG.SIGNUP.REQUIRES_APPROVAL + ? 'filter:api.user.request-signup.allowed.result' + : 'filter:api.user.signup.allowed.result' ) const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) @@ -273,6 +280,7 @@ class ServerConfigManager { allowed, allowedForCurrentIP, minimumAge: CONFIG.SIGNUP.MINIMUM_AGE, + requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL, requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION } diff --git a/server/lib/signup.ts b/server/lib/signup.ts index f094531eb..f19232621 100644 --- a/server/lib/signup.ts +++ b/server/lib/signup.ts @@ -4,11 +4,24 @@ import { UserModel } from '../models/user/user' const isCidr = require('is-cidr') -async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> { +export type SignupMode = 'direct-registration' | 'request-registration' + +async function isSignupAllowed (options: { + signupMode: SignupMode + + ip: string // For plugins + body?: any +}): Promise<{ allowed: boolean, errorMessage?: string }> { + const { signupMode } = options + if (CONFIG.SIGNUP.ENABLED === false) { return { allowed: false } } + if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) { + return { allowed: false } + } + // No limit and signup is enabled if (CONFIG.SIGNUP.LIMIT === -1) { return { allowed: true } diff --git a/server/lib/user.ts b/server/lib/user.ts index 2e433da04..ffb57944a 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -10,7 +10,7 @@ import { sequelizeTypescript } from '../initializers/database' import { AccountModel } from '../models/account/account' import { UserNotificationSettingModel } from '../models/user/user-notification-setting' import { MAccountDefault, MChannelActor } from '../types/models' -import { MUser, MUserDefault, MUserId } from '../types/models/user' +import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user' import { generateAndSaveActorKeys } from './activitypub/actors' import { getLocalAccountActivityPubUrl } from './activitypub/url' import { Emailer } from './emailer' @@ -97,7 +97,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: { }) userCreated.Account = accountCreated - const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames) + const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames }) const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t) const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) @@ -160,15 +160,28 @@ async function createApplicationActor (applicationId: number) { // --------------------------------------------------------------------------- async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) { - const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id) - let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString + const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id) + let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}` - if (isPendingEmail) url += '&isPendingEmail=true' + if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true' + + const to = isPendingEmail + ? user.pendingEmail + : user.email - const email = isPendingEmail ? user.pendingEmail : user.email const username = user.username - Emailer.Instance.addVerifyEmailJob(username, email, url) + Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false }) +} + +async function sendVerifyRegistrationEmail (registration: MRegistration) { + const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id) + const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}` + + const to = registration.email + const username = registration.username + + Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true }) } // --------------------------------------------------------------------------- @@ -232,7 +245,10 @@ export { createApplicationActor, createUserAccountAndChannelAndPlaylist, createLocalAccountWithoutKeys, + sendVerifyUserEmail, + sendVerifyRegistrationEmail, + isAbleToUploadVideo, buildUser } @@ -264,7 +280,13 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction | return UserNotificationSettingModel.create(values, { transaction: t }) } -async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) { +async function buildChannelAttributes (options: { + user: MUser + transaction?: Transaction + channelNames?: ChannelNames +}) { + const { user, transaction, channelNames } = options + if (channelNames) return channelNames const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction) diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index 3a7daa573..c2dbfadb7 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts @@ -29,6 +29,7 @@ const customConfigUpdateValidator = [ body('signup.enabled').isBoolean(), body('signup.limit').isInt(), body('signup.requiresEmailVerification').isBoolean(), + body('signup.requiresApproval').isBoolean(), body('signup.minimumAge').isInt(), body('admin.email').isEmail(), diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 9bc8887ff..1d0964667 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -21,8 +21,10 @@ export * from './server' export * from './sort' export * from './static' export * from './themes' +export * from './user-email-verification' export * from './user-history' export * from './user-notifications' +export * from './user-registrations' export * from './user-subscriptions' export * from './users' export * from './videos' diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts new file mode 100644 index 000000000..dbc7dda06 --- /dev/null +++ b/server/middlewares/validators/shared/user-registrations.ts @@ -0,0 +1,60 @@ +import express from 'express' +import { UserRegistrationModel } from '@server/models/user/user-registration' +import { MRegistration } from '@server/types/models' +import { forceNumber, pick } from '@shared/core-utils' +import { HttpStatusCode } from '@shared/models' + +function checkRegistrationIdExist (idArg: number | string, res: express.Response) { + const id = forceNumber(idArg) + return checkRegistrationExist(() => UserRegistrationModel.load(id), res) +} + +function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) { + return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse) +} + +async function checkRegistrationHandlesDoNotAlreadyExist (options: { + username: string + channelHandle: string + email: string + res: express.Response +}) { + const { res } = options + + const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ])) + + if (registration) { + res.fail({ + status: HttpStatusCode.CONFLICT_409, + message: 'Registration with this username, channel name or email already exists.' + }) + return false + } + + return true +} + +async function checkRegistrationExist (finder: () => Promise, 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 +} + +export { + checkRegistrationIdExist, + checkRegistrationEmailExist, + checkRegistrationHandlesDoNotAlreadyExist, + checkRegistrationExist +} diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts index b8f1436d3..030adc9f7 100644 --- a/server/middlewares/validators/shared/users.ts +++ b/server/middlewares/validators/shared/users.ts @@ -14,7 +14,7 @@ function checkUserEmailExist (email: string, res: express.Response, abortRespons return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) } -async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { +async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) { const user = await UserModel.loadByUsernameOrEmail(username, email) if (user) { @@ -58,6 +58,6 @@ async function checkUserExist (finder: () => Promise, res: express export { checkUserIdExist, checkUserEmailExist, - checkUserNameOrEmailDoesNotAlreadyExist, + checkUserNameOrEmailDoNotAlreadyExist, checkUserExist } diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 7d0639107..e6cc46317 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -1,9 +1,41 @@ import express from 'express' import { query } from 'express-validator' - import { SORTABLE_COLUMNS } from '../../initializers/constants' import { areValidationErrors } from './shared' +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 accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) +export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) + +export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS) + +// --------------------------------------------------------------------------- + function checkSortFactory (columns: string[], tags: string[] = []) { return checkSort(createSortableColumns(columns), tags) } @@ -27,64 +59,3 @@ function createSortableColumns (sortableColumns: string[]) { return sortableColumns.concat(sortableColumnDesc) } - -const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS) -const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) -const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) -const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) -const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS) -const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS) -const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH) -const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) -const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH) -const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS) -const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) -const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES) -const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS) -const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS) -const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS) -const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING) -const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) -const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) -const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) -const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS) -const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) -const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) -const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) -const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) -const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) - -const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) -const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) - -// --------------------------------------------------------------------------- - -export { - adminUsersSortValidator, - abusesSortValidator, - videoChannelsSortValidator, - videoImportsSortValidator, - videoCommentsValidator, - videosSearchSortValidator, - videosSortValidator, - blacklistSortValidator, - accountsSortValidator, - instanceFollowersSortValidator, - instanceFollowingSortValidator, - jobsSortValidator, - videoCommentThreadsSortValidator, - videoRatesSortValidator, - userSubscriptionsSortValidator, - availablePluginsSortValidator, - videoChannelsSearchSortValidator, - accountsBlocklistSortValidator, - serversBlocklistSortValidator, - userNotificationsSortValidator, - videoPlaylistsSortValidator, - videoRedundanciesSortValidator, - videoPlaylistsSearchSortValidator, - accountsFollowersSortValidator, - videoChannelsFollowersSortValidator, - videoChannelSyncsSortValidator, - pluginsSortValidator -} diff --git a/server/middlewares/validators/user-email-verification.ts b/server/middlewares/validators/user-email-verification.ts new file mode 100644 index 000000000..74702a8f5 --- /dev/null +++ b/server/middlewares/validators/user-email-verification.ts @@ -0,0 +1,94 @@ +import express from 'express' +import { body, param } from 'express-validator' +import { toBooleanOrNull } from '@server/helpers/custom-validators/misc' +import { HttpStatusCode } from '@shared/models' +import { logger } from '../../helpers/logger' +import { Redis } from '../../lib/redis' +import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared' +import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations' + +const usersAskSendVerifyEmailValidator = [ + 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 [ userExists, registrationExists ] = await Promise.all([ + checkUserEmailExist(req.body.email, res, false), + checkRegistrationEmailExist(req.body.email, res, false) + ]) + + if (!userExists && !registrationExists) { + logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.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 ask verification email of a user that uses a plugin authentication.' + }) + } + + return next() + } +] + +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() + } +] + +// --------------------------------------------------------------------------- + +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() + } +] + +// --------------------------------------------------------------------------- + +export { + usersAskSendVerifyEmailValidator, + usersVerifyEmailValidator, + + registrationVerifyEmailValidator +} diff --git a/server/middlewares/validators/user-registrations.ts b/server/middlewares/validators/user-registrations.ts new file mode 100644 index 000000000..e263c27c5 --- /dev/null +++ b/server/middlewares/validators/user-registrations.ts @@ -0,0 +1,203 @@ +import express from 'express' +import { body, param, query, ValidationChain } from 'express-validator' +import { exists, isIdValid } from '@server/helpers/custom-validators/misc' +import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration' +import { CONFIG } from '@server/initializers/config' +import { Hooks } from '@server/lib/plugins/hooks' +import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models' +import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users' +import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' +import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup' +import { ActorModel } from '../../models/actor/actor' +import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared' +import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations' + +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: '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 || 'User registration is not enabled, user limit is reached or registration requires approval.' + }) + } + + 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: 'You are not on a network authorized for registration.' + }) + } + + return next() + } +] + +// --------------------------------------------------------------------------- + +const acceptOrRejectRegistrationValidator = [ + param('registrationId') + .custom(isIdValid), + + body('moderationResponse') + .custom(isRegistrationModerationResponseValid), + + 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: '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 { + usersDirectRegistrationValidator, + usersRequestRegistrationValidator, + + ensureUserRegistrationAllowedFactory, + ensureUserRegistrationAllowedForIP, + + getRegistrationValidator, + listRegistrationsValidator, + + acceptOrRejectRegistrationValidator +} + +// --------------------------------------------------------------------------- + +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(body.username, body.email, res)) return + + if (body.channel) { + if (!body.channel.name || !body.channel.displayName) { + return res.fail({ message: '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: 'Channel name cannot be the same as user username.' }) + } + + const existing = await ActorModel.loadLocalByName(body.channel.name) + if (existing) { + return res.fail({ + status: HttpStatusCode.CONFLICT_409, + message: `Channel with name ${body.channel.name} already exists.` + }) + } + } + + return next() + } + ] +} diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index 64bd9ca70..f7033f44a 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -1,8 +1,7 @@ import express from 'express' import { body, param, query } from 'express-validator' -import { Hooks } from '@server/lib/plugins/hooks' import { forceNumber } from '@shared/core-utils' -import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' +import { HttpStatusCode, UserRight, UserRole } from '@shared/models' import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { @@ -24,17 +23,16 @@ import { isUserVideoQuotaValid, isUserVideosHistoryEnabledValid } from '../../helpers/custom-validators/users' -import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' +import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' import { logger } from '../../helpers/logger' import { isThemeRegistered } from '../../lib/plugins/theme-utils' import { Redis } from '../../lib/redis' -import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' import { ActorModel } from '../../models/actor/actor' import { areValidationErrors, checkUserEmailExist, checkUserIdExist, - checkUserNameOrEmailDoesNotAlreadyExist, + checkUserNameOrEmailDoNotAlreadyExist, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam @@ -81,7 +79,7 @@ const usersAddValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res, { omitBodyLog: true })) return - if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return + if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return const authUser = res.locals.oauth.token.User if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { @@ -109,51 +107,6 @@ const usersAddValidator = [ } ] -const usersRegisterValidator = [ - 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), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (areValidationErrors(req, res, { omitBodyLog: true })) return - if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return - - const body: UserRegister = req.body - if (body.channel) { - if (!body.channel.name || !body.channel.displayName) { - return res.fail({ message: '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: 'Channel name cannot be the same as user username.' }) - } - - const existing = await ActorModel.loadLocalByName(body.channel.name) - if (existing) { - return res.fail({ - status: HttpStatusCode.CONFLICT_409, - message: `Channel with name ${body.channel.name} already exists.` - }) - } - } - - return next() - } -] - const usersRemoveValidator = [ param('id') .custom(isIdValid), @@ -365,45 +318,6 @@ const usersVideosValidator = [ } ] -const ensureUserRegistrationAllowed = [ - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - const allowedParams = { - body: req.body, - ip: req.ip - } - - const allowedResult = await Hooks.wrapPromiseFun( - isSignupAllowed, - allowedParams, - 'filter:api.user.signup.allowed.result' - ) - - if (allowedResult.allowed === false) { - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.' - }) - } - - 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: 'You are not on a network authorized for registration.' - }) - } - - return next() - } -] - const usersAskResetPasswordValidator = [ body('email') .isEmail(), @@ -455,58 +369,6 @@ const usersResetPasswordValidator = [ } ] -const usersAskSendVerifyEmailValidator = [ - 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 exists = await checkUserEmailExist(req.body.email, res, false) - if (!exists) { - logger.debug('User with email %s does not exist (asking verify email).', req.body.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 ask verification email of a user that uses a plugin authentication.' - }) - } - - return next() - } -] - -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.getVerifyEmailLink(user.id) - - if (redisVerificationString !== req.body.verificationString) { - return res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Invalid verification string.' - }) - } - - return next() - } -] - const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => { return [ body('currentPassword').optional().custom(exists), @@ -603,21 +465,16 @@ export { usersListValidator, usersAddValidator, deleteMeValidator, - usersRegisterValidator, usersBlockingValidator, usersRemoveValidator, usersUpdateValidator, usersUpdateMeValidator, usersVideoRatingValidator, usersCheckCurrentPasswordFactory, - ensureUserRegistrationAllowed, - ensureUserRegistrationAllowedForIP, usersGetValidator, usersVideosValidator, usersAskResetPasswordValidator, usersResetPasswordValidator, - usersAskSendVerifyEmailValidator, - usersVerifyEmailValidator, userAutocompleteValidator, ensureAuthUserOwnsAccountValidator, ensureCanModerateUser, diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts index d11546df0..7b29807a3 100644 --- a/server/models/user/sql/user-notitication-list-query-builder.ts +++ b/server/models/user/sql/user-notitication-list-query-builder.ts @@ -180,7 +180,9 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type", "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename", "Account->Actor->Server"."id" AS "Account.Actor.Server.id", - "Account->Actor->Server"."host" AS "Account.Actor.Server.host"` + "Account->Actor->Server"."host" AS "Account.Actor.Server.host", + "UserRegistration"."id" AS "UserRegistration.id", + "UserRegistration"."username" AS "UserRegistration.username"` } private getJoins () { @@ -196,74 +198,76 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id" ) ON "UserNotificationModel"."videoId" = "Video"."id" - LEFT JOIN ( - "videoComment" AS "VideoComment" - INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id" - INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id" - LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars" - ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId" - AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "VideoComment->Account->Actor->Server" - ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id" - INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id" - ) ON "UserNotificationModel"."commentId" = "VideoComment"."id" + LEFT JOIN ( + "videoComment" AS "VideoComment" + INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id" + INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id" + LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars" + ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId" + AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} + LEFT JOIN "server" AS "VideoComment->Account->Actor->Server" + ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id" + INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id" + ) ON "UserNotificationModel"."commentId" = "VideoComment"."id" + + LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id" + LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId" + LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id" + LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId" + LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment" + ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id" + LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video" + ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id" + LEFT JOIN ( + "account" AS "Abuse->FlaggedAccount" + INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id" + LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars" + ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId" + AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR} + LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server" + ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id" + ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id" - LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id" - LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId" - LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id" - LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId" - LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment" - ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id" - LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video" - ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id" - LEFT JOIN ( - "account" AS "Abuse->FlaggedAccount" - INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id" - LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars" - ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId" - AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server" - ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id" - ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id" + LEFT JOIN ( + "videoBlacklist" AS "VideoBlacklist" + INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id" + ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id" - LEFT JOIN ( - "videoBlacklist" AS "VideoBlacklist" - INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id" - ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id" + LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id" + LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id" - LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id" - LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id" + LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id" - LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id" + LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id" - LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id" + LEFT JOIN ( + "actorFollow" AS "ActorFollow" + INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id" + INNER JOIN "account" AS "ActorFollow->ActorFollower->Account" + ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId" + LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars" + ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId" + AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR} + LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server" + ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id" + INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id" + LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel" + ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId" + LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account" + ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId" + LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server" + ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id" + ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id" - LEFT JOIN ( - "actorFollow" AS "ActorFollow" - INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id" - INNER JOIN "account" AS "ActorFollow->ActorFollower->Account" - ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId" - LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars" - ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId" - AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server" - ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id" - INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id" - LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel" - ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId" - LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account" - ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId" - LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server" - ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id" - ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id" + LEFT JOIN ( + "account" AS "Account" + INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id" + LEFT JOIN "actorImage" AS "Account->Actor->Avatars" + ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId" + AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} + LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" + ) ON "UserNotificationModel"."accountId" = "Account"."id" - LEFT JOIN ( - "account" AS "Account" - INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id" - LEFT JOIN "actorImage" AS "Account->Actor->Avatars" - ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId" - AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" - ) ON "UserNotificationModel"."accountId" = "Account"."id"` + LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"` } } diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts index 6e134158f..667ee7f5f 100644 --- a/server/models/user/user-notification.ts +++ b/server/models/user/user-notification.ts @@ -20,6 +20,7 @@ import { VideoCommentModel } from '../video/video-comment' import { VideoImportModel } from '../video/video-import' import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' import { UserModel } from './user' +import { UserRegistrationModel } from './user-registration' @Table({ tableName: 'userNotification', @@ -98,6 +99,14 @@ import { UserModel } from './user' [Op.ne]: null } } + }, + { + fields: [ 'userRegistrationId' ], + where: { + userRegistrationId: { + [Op.ne]: null + } + } } ] as (ModelIndexesOptions & { where?: WhereOptions })[] }) @@ -241,6 +250,18 @@ export class UserNotificationModel extends Model UserRegistrationModel) + @Column + userRegistrationId: number + + @BelongsTo(() => UserRegistrationModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + UserRegistration: UserRegistrationModel + static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { const where = { userId } @@ -416,6 +437,10 @@ export class UserNotificationModel extends Model>> { + + @AllowNull(false) + @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state')) + @Column + state: UserRegistrationState + + @AllowNull(false) + @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason')) + @Column(DataType.TEXT) + registrationReason: string + + @AllowNull(true) + @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true)) + @Column(DataType.TEXT) + moderationResponse: string + + @AllowNull(true) + @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true)) + @Column + password: string + + @AllowNull(false) + @Column + username: string + + @AllowNull(false) + @IsEmail + @Column(DataType.STRING(400)) + email: string + + @AllowNull(true) + @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true)) + @Column + emailVerified: boolean + + @AllowNull(true) + @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true)) + @Column + accountDisplayName: string + + @AllowNull(true) + @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true)) + @Column + channelHandle: string + + @AllowNull(true) + @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true)) + @Column + channelDisplayName: string + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => UserModel) + @Column + userId: number + + @BelongsTo(() => UserModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'SET NULL' + }) + User: UserModel + + @BeforeCreate + static async cryptPasswordIfNeeded (instance: UserRegistrationModel) { + instance.password = await cryptPassword(instance.password) + } + + static load (id: number): Promise { + return UserRegistrationModel.findByPk(id) + } + + static loadByEmail (email: string): Promise { + const query = { + where: { email } + } + + return UserRegistrationModel.findOne(query) + } + + static loadByEmailOrUsername (emailOrUsername: string): Promise { + const query = { + where: { + [Op.or]: [ + { email: emailOrUsername }, + { username: emailOrUsername } + ] + } + } + + return UserRegistrationModel.findOne(query) + } + + static loadByEmailOrHandle (options: { + email: string + username: string + channelHandle?: string + }): Promise { + const { email, username, channelHandle } = options + + let or: WhereOptions = [ + { email }, + { channelHandle: username }, + { username } + ] + + if (channelHandle) { + or = or.concat([ + { username: channelHandle }, + { channelHandle } + ]) + } + + const query = { + where: { + [Op.or]: or + } + } + + return UserRegistrationModel.findOne(query) + } + + // --------------------------------------------------------------------------- + + static listForApi (options: { + start: number + count: number + sort: string + search?: string + }) { + const { start, count, sort, search } = options + + const where: WhereOptions = {} + + if (search) { + Object.assign(where, { + [Op.or]: [ + { + email: { + [Op.iLike]: '%' + search + '%' + } + }, + { + username: { + [Op.iLike]: '%' + search + '%' + } + } + ] + }) + } + + const query: FindOptions = { + offset: start, + limit: count, + order: getSort(sort), + where, + include: [ + { + model: UserModel.unscoped(), + required: false + } + ] + } + + return Promise.all([ + UserRegistrationModel.count(query), + UserRegistrationModel.findAll(query) + ]).then(([ total, data ]) => ({ total, data })) + } + + // --------------------------------------------------------------------------- + + toFormattedJSON (this: MRegistrationFormattable): UserRegistration { + return { + id: this.id, + + state: { + id: this.state, + label: USER_REGISTRATION_STATES[this.state] + }, + + registrationReason: this.registrationReason, + moderationResponse: this.moderationResponse, + + username: this.username, + email: this.email, + emailVerified: this.emailVerified, + + accountDisplayName: this.accountDisplayName, + + channelHandle: this.channelHandle, + channelDisplayName: this.channelDisplayName, + + createdAt: this.createdAt, + updatedAt: this.updatedAt, + + user: this.User + ? { id: this.User.id } + : null + } + } +} diff --git a/server/models/user/user.ts b/server/models/user/user.ts index 0932a367a..c5c8a1b30 100644 --- a/server/models/user/user.ts +++ b/server/models/user/user.ts @@ -441,16 +441,17 @@ export class UserModel extends Model>> { }) OAuthTokens: OAuthTokenModel[] + // Used if we already set an encrypted password in user model + skipPasswordEncryption = false + @BeforeCreate @BeforeUpdate - static cryptPasswordIfNeeded (instance: UserModel) { - if (instance.changed('password') && instance.password) { - return cryptPassword(instance.password) - .then(hash => { - instance.password = hash - return undefined - }) - } + static async cryptPasswordIfNeeded (instance: UserModel) { + if (instance.skipPasswordEncryption) return + if (!instance.changed('password')) return + if (!instance.password) return + + instance.password = await cryptPassword(instance.password) } @AfterUpdate diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 6fea4dac2..c1c379b98 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -8,6 +8,7 @@ import { MActorUrl, MChannelBannerAccountDefault, MChannelSyncChannel, + MRegistration, MStreamingPlaylist, MUserAccountUrl, MVideoChangeOwnershipFull, @@ -171,6 +172,7 @@ declare module 'express' { actorFull?: MActorFull user?: MUserDefault + userRegistration?: MRegistration server?: MServer diff --git a/server/types/models/user/index.ts b/server/types/models/user/index.ts index 6657b2128..5738f4107 100644 --- a/server/types/models/user/index.ts +++ b/server/types/models/user/index.ts @@ -1,4 +1,5 @@ export * from './user' export * from './user-notification' export * from './user-notification-setting' +export * from './user-registration' export * from './user-video-history' diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts index d4715a0b6..a732c8aa9 100644 --- a/server/types/models/user/user-notification.ts +++ b/server/types/models/user/user-notification.ts @@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse import { ApplicationModel } from '@server/models/application/application' import { PluginModel } from '@server/models/server/plugin' import { UserNotificationModel } from '@server/models/user/user-notification' +import { UserRegistrationModel } from '@server/models/user/user-registration' import { PickWith, PickWithOpt } from '@shared/typescript-utils' import { AbuseModel } from '../../../models/abuse/abuse' import { AccountModel } from '../../../models/account/account' @@ -94,13 +95,16 @@ export module UserNotificationIncludes { export type ApplicationInclude = Pick + + export type UserRegistrationInclude = + Pick } // ############################################################################ export type MUserNotification = Omit + 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'> // ############################################################################ @@ -114,4 +118,5 @@ export type UserNotificationModelForApi = Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & Use<'Plugin', UserNotificationIncludes.PluginInclude> & Use<'Application', UserNotificationIncludes.ApplicationInclude> & - Use<'Account', UserNotificationIncludes.AccountIncludeActor> + Use<'Account', UserNotificationIncludes.AccountIncludeActor> & + Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude> diff --git a/server/types/models/user/user-registration.ts b/server/types/models/user/user-registration.ts new file mode 100644 index 000000000..216423cc9 --- /dev/null +++ b/server/types/models/user/user-registration.ts @@ -0,0 +1,15 @@ +import { UserRegistrationModel } from '@server/models/user/user-registration' +import { PickWith } from '@shared/typescript-utils' +import { MUserId } from './user' + +type Use = PickWith + +// ############################################################################ + +export type MRegistration = Omit + +// ############################################################################ + +export type MRegistrationFormattable = + MRegistration & + Use<'User', MUserId> diff --git a/shared/core-utils/users/user-role.ts b/shared/core-utils/users/user-role.ts index cc757d779..5f3b9a10f 100644 --- a/shared/core-utils/users/user-role.ts +++ b/shared/core-utils/users/user-role.ts @@ -23,7 +23,8 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = { UserRight.MANAGE_ACCOUNTS_BLOCKLIST, UserRight.MANAGE_SERVERS_BLOCKLIST, UserRight.MANAGE_USERS, - UserRight.SEE_ALL_COMMENTS + UserRight.SEE_ALL_COMMENTS, + UserRight.MANAGE_REGISTRATIONS ], [UserRole.USER]: [] diff --git a/shared/models/plugins/server/server-hook.model.ts b/shared/models/plugins/server/server-hook.model.ts index f11d2050b..dd9cc3ad6 100644 --- a/shared/models/plugins/server/server-hook.model.ts +++ b/shared/models/plugins/server/server-hook.model.ts @@ -91,6 +91,10 @@ export const serverFilterHookObject = { // Filter result used to check if a user can register on the instance 'filter:api.user.signup.allowed.result': true, + // Filter result used to check if a user can send a registration request on the instance + // PeerTube >= 5.1 + 'filter:api.user.request-signup.allowed.result': true, + // Filter result used to check if video/torrent download is allowed 'filter:api.download.video.allowed.result': true, 'filter:api.download.torrent.allowed.result': true, @@ -156,6 +160,9 @@ export const serverActionHookObject = { 'action:api.user.unblocked': true, // Fired when a user registered on the instance 'action:api.user.registered': true, + // Fired when a user requested registration on the instance + // PeerTube >= 5.1 + 'action:api.user.requested-registration': true, // Fired when an admin/moderator created a user 'action:api.user.created': true, // Fired when a user is removed by an admin/moderator diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 7d9d570b1..846bf6159 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -83,6 +83,7 @@ export interface CustomConfig { signup: { enabled: boolean limit: number + requiresApproval: boolean requiresEmailVerification: boolean minimumAge: number } diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 3b6d0597c..d0bd9a00f 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -131,6 +131,7 @@ export interface ServerConfig { allowed: boolean allowedForCurrentIP: boolean requiresEmailVerification: boolean + requiresApproval: boolean minimumAge: number } diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts index 0e70ea0a7..a39cde1b3 100644 --- a/shared/models/server/server-error-code.enum.ts +++ b/shared/models/server/server-error-code.enum.ts @@ -39,7 +39,13 @@ export const enum ServerErrorCode { */ INCORRECT_FILES_IN_TORRENT = 'incorrect_files_in_torrent', - COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video' + COMMENT_NOT_ASSOCIATED_TO_VIDEO = 'comment_not_associated_to_video', + + MISSING_TWO_FACTOR = 'missing_two_factor', + INVALID_TWO_FACTOR = 'invalid_two_factor', + + ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval', + ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected' } /** @@ -70,5 +76,5 @@ export const enum OAuth2ErrorCode { * * @see https://github.com/oauthjs/node-oauth2-server/blob/master/lib/errors/invalid-token-error.js */ - INVALID_TOKEN = 'invalid_token', + INVALID_TOKEN = 'invalid_token' } diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts index 32f7a441c..4a050c870 100644 --- a/shared/models/users/index.ts +++ b/shared/models/users/index.ts @@ -1,3 +1,4 @@ +export * from './registration' export * from './two-factor-enable-result.model' export * from './user-create-result.model' export * from './user-create.model' @@ -6,7 +7,6 @@ export * from './user-login.model' export * from './user-notification-setting.model' export * from './user-notification.model' export * from './user-refresh-token.model' -export * from './user-register.model' export * from './user-right.enum' export * from './user-role' export * from './user-scoped-token' diff --git a/shared/models/users/registration/index.ts b/shared/models/users/registration/index.ts new file mode 100644 index 000000000..593740c4f --- /dev/null +++ b/shared/models/users/registration/index.ts @@ -0,0 +1,5 @@ +export * from './user-register.model' +export * from './user-registration-request.model' +export * from './user-registration-state.model' +export * from './user-registration-update-state.model' +export * from './user-registration.model' diff --git a/shared/models/users/user-register.model.ts b/shared/models/users/registration/user-register.model.ts similarity index 100% rename from shared/models/users/user-register.model.ts rename to shared/models/users/registration/user-register.model.ts diff --git a/shared/models/users/registration/user-registration-request.model.ts b/shared/models/users/registration/user-registration-request.model.ts new file mode 100644 index 000000000..6c38817e0 --- /dev/null +++ b/shared/models/users/registration/user-registration-request.model.ts @@ -0,0 +1,5 @@ +import { UserRegister } from './user-register.model' + +export interface UserRegistrationRequest extends UserRegister { + registrationReason: string +} diff --git a/shared/models/users/registration/user-registration-state.model.ts b/shared/models/users/registration/user-registration-state.model.ts new file mode 100644 index 000000000..e4c835f78 --- /dev/null +++ b/shared/models/users/registration/user-registration-state.model.ts @@ -0,0 +1,5 @@ +export const enum UserRegistrationState { + PENDING = 1, + REJECTED = 2, + ACCEPTED = 3 +} diff --git a/shared/models/users/registration/user-registration-update-state.model.ts b/shared/models/users/registration/user-registration-update-state.model.ts new file mode 100644 index 000000000..636e22c32 --- /dev/null +++ b/shared/models/users/registration/user-registration-update-state.model.ts @@ -0,0 +1,3 @@ +export interface UserRegistrationUpdateState { + moderationResponse: string +} diff --git a/shared/models/users/registration/user-registration.model.ts b/shared/models/users/registration/user-registration.model.ts new file mode 100644 index 000000000..0d74dc28b --- /dev/null +++ b/shared/models/users/registration/user-registration.model.ts @@ -0,0 +1,29 @@ +import { UserRegistrationState } from './user-registration-state.model' + +export interface UserRegistration { + id: number + + state: { + id: UserRegistrationState + label: string + } + + registrationReason: string + moderationResponse: string + + username: string + email: string + emailVerified: boolean + + accountDisplayName: string + + channelHandle: string + channelDisplayName: string + + createdAt: Date + updatedAt: Date + + user?: { + id: number + } +} diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts index 0fd7a7181..294c921bd 100644 --- a/shared/models/users/user-notification.model.ts +++ b/shared/models/users/user-notification.model.ts @@ -32,7 +32,9 @@ export const enum UserNotificationType { NEW_PLUGIN_VERSION = 17, NEW_PEERTUBE_VERSION = 18, - MY_VIDEO_STUDIO_EDITION_FINISHED = 19 + MY_VIDEO_STUDIO_EDITION_FINISHED = 19, + + NEW_USER_REGISTRATION_REQUEST = 20 } export interface VideoInfo { @@ -126,6 +128,11 @@ export interface UserNotification { latestVersion: string } + registration?: { + id: number + username: string + } + createdAt: string updatedAt: string } diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 9c6828aa5..42e5c8cd6 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -43,5 +43,7 @@ export const enum UserRight { MANAGE_VIDEO_FILES = 25, RUN_VIDEO_TRANSCODING = 26, - MANAGE_VIDEO_IMPORTS = 27 + MANAGE_VIDEO_IMPORTS = 27, + + MANAGE_REGISTRATIONS = 28 } -- 2.41.0