Browse Source

Implement signup approval in server

Chocobozzz 1 year ago
parent
commit
e364e31e25
59 changed files with 1561 additions and 448 deletions
  1. 6 0
      config/default.yaml
  2. 6 0
      config/production.yaml.example
  3. 1 0
      config/test.yaml
  4. 1 0
      server/controllers/api/config.ts
  5. 72 0
      server/controllers/api/users/email-verification.ts
  6. 7 92
      server/controllers/api/users/index.ts
  7. 236 0
      server/controllers/api/users/registrations.ts
  8. 25 0
      server/helpers/custom-validators/user-registration.ts
  9. 5 0
      server/initializers/checker-after-init.ts
  10. 1 1
      server/initializers/checker-before-init.ts
  11. 1 0
      server/initializers/config.ts
  12. 17 3
      server/initializers/constants.ts
  13. 4 2
      server/initializers/database.ts
  14. 58 0
      server/initializers/migrations/0750-user-registration.ts
  15. 25 4
      server/lib/auth/oauth.ts
  16. 47 7
      server/lib/emailer.ts
  17. 1 11
      server/lib/emails/common/base.pug
  18. 10 0
      server/lib/emails/user-registration-request-accepted/html.pug
  19. 9 0
      server/lib/emails/user-registration-request-rejected/html.pug
  20. 9 0
      server/lib/emails/user-registration-request/html.pug
  21. 14 12
      server/lib/emails/verify-email/html.pug
  22. 14 5
      server/lib/notifier/notifier.ts
  23. 2 2
      server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
  24. 2 1
      server/lib/notifier/shared/instance/index.ts
  25. 48 0
      server/lib/notifier/shared/instance/registration-request-for-moderators.ts
  26. 23 7
      server/lib/redis.ts
  27. 10 2
      server/lib/server-config-manager.ts
  28. 14 1
      server/lib/signup.ts
  29. 30 8
      server/lib/user.ts
  30. 1 0
      server/middlewares/validators/config.ts
  31. 2 0
      server/middlewares/validators/index.ts
  32. 60 0
      server/middlewares/validators/shared/user-registrations.ts
  33. 2 2
      server/middlewares/validators/shared/users.ts
  34. 33 62
      server/middlewares/validators/sort.ts
  35. 94 0
      server/middlewares/validators/user-email-verification.ts
  36. 203 0
      server/middlewares/validators/user-registrations.ts
  37. 4 147
      server/middlewares/validators/users.ts
  38. 67 63
      server/models/user/sql/user-notitication-list-query-builder.ts
  39. 26 0
      server/models/user/user-notification.ts
  40. 259 0
      server/models/user/user-registration.ts
  41. 9 8
      server/models/user/user.ts
  42. 2 0
      server/types/express.d.ts
  43. 1 0
      server/types/models/user/index.ts
  44. 7 2
      server/types/models/user/user-notification.ts
  45. 15 0
      server/types/models/user/user-registration.ts
  46. 2 1
      shared/core-utils/users/user-role.ts
  47. 7 0
      shared/models/plugins/server/server-hook.model.ts
  48. 1 0
      shared/models/server/custom-config.model.ts
  49. 1 0
      shared/models/server/server-config.model.ts
  50. 8 2
      shared/models/server/server-error-code.enum.ts
  51. 1 1
      shared/models/users/index.ts
  52. 5 0
      shared/models/users/registration/index.ts
  53. 0 0
      shared/models/users/registration/user-register.model.ts
  54. 5 0
      shared/models/users/registration/user-registration-request.model.ts
  55. 5 0
      shared/models/users/registration/user-registration-state.model.ts
  56. 3 0
      shared/models/users/registration/user-registration-update-state.model.ts
  57. 29 0
      shared/models/users/registration/user-registration.model.ts
  58. 8 1
      shared/models/users/user-notification.model.ts
  59. 3 1
      shared/models/users/user-right.enum.ts

+ 6 - 0
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: []

+ 6 - 0
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: []

+ 1 - 0
config/test.yaml

@@ -74,6 +74,7 @@ cache:
 
 signup:
   enabled: true
+  requires_approval: false
   requires_email_verification: false
 
 transcoding:

+ 1 - 0
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
     },

+ 72 - 0
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()
+}

+ 7 - 92
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())
 

+ 236 - 0
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)
+}

+ 25 - 0
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
+}

+ 5 - 0
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.')
     }

+ 1 - 1
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',

+ 1 - 0
server/initializers/config.ts

@@ -305,6 +305,7 @@ const CONFIG = {
   },
   SIGNUP: {
     get ENABLED () { return config.get<boolean>('signup.enabled') },
+    get REQUIRES_APPROVAL () { return config.get<boolean>('signup.requires_approval') },
     get LIMIT () { return config.get<number>('signup.limit') },
     get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
     get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },

+ 17 - 3
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,

+ 4 - 2
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

+ 58 - 0
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<void> {
+  {
+    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
+}

+ 25 - 4
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]) {

+ 47 - 7
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'
       }
     }
 

+ 1 - 11
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;')

+ 10 - 0
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}]

+ 9 - 0
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}

+ 9 - 0
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].

+ 14 - 12
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.

+ 14 - 5
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
 

+ 2 - 2
server/lib/notifier/shared/instance/registration-for-moderators.ts → 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 <MUserDefault> {
+export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
   private moderators: MUserDefault[]
 
   async prepare () {
@@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification <MUserDefaul
     return {
       template: 'user-registered',
       to,
-      subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
+      subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
       locals: {
         user: this.payload
       }

+ 2 - 1
server/lib/notifier/shared/instance/index.ts

@@ -1,3 +1,4 @@
 export * from './new-peertube-version-for-admins'
 export * from './new-plugin-version-for-admins'
-export * from './registration-for-moderators'
+export * from './direct-registration-for-moderators'
+export * from './registration-request-for-moderators'

+ 48 - 0
server/lib/notifier/shared/instance/registration-request-for-moderators.ts

@@ -0,0 +1,48 @@
+import { logger } from '@server/helpers/logger'
+import { UserModel } from '@server/models/user/user'
+import { UserNotificationModel } from '@server/models/user/user-notification'
+import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
+import { UserNotificationType, UserRight } from '@shared/models'
+import { AbstractNotification } from '../common/abstract-notification'
+
+export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
+  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<UserNotificationModelForApi>({
+      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
+      }
+    }
+  }
+}

+ 23 - 7
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) {

+ 10 - 2
server/lib/server-config-manager.ts

@@ -261,10 +261,17 @@ class ServerConfigManager {
   async getServerConfig (ip?: string): Promise<ServerConfig> {
     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
     }
 

+ 14 - 1
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 }

+ 30 - 8
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)

+ 1 - 0
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(),

+ 2 - 0
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'

+ 60 - 0
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<MRegistration>, res: express.Response, abortResponse = true) {
+  const registration = await finder()
+
+  if (!registration) {
+    if (abortResponse === true) {
+      res.fail({
+        status: HttpStatusCode.NOT_FOUND_404,
+        message: 'User not found'
+      })
+    }
+
+    return false
+  }
+
+  res.locals.userRegistration = registration
+  return true
+}
+
+export {
+  checkRegistrationIdExist,
+  checkRegistrationEmailExist,
+  checkRegistrationHandlesDoNotAlreadyExist,
+  checkRegistrationExist
+}

+ 2 - 2
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<MUserDefault>, res: express
 export {
   checkUserIdExist,
   checkUserEmailExist,
-  checkUserNameOrEmailDoesNotAlreadyExist,
+  checkUserNameOrEmailDoNotAlreadyExist,
   checkUserExist
 }

+ 33 - 62
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
-}

+ 94 - 0
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
+}

+ 203 - 0
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()
+    }
+  ]
+}

+ 4 - 147
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,

+ 67 - 63
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"`
   }
 }

+ 26 - 0
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<Partial<AttributesOnly<UserNoti
   })
   Application: ApplicationModel
 
+  @ForeignKey(() => 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<Partial<AttributesOnly<UserNoti
       ? { latestVersion: this.Application.latestPeerTubeVersion }
       : undefined
 
+    const registration = this.UserRegistration
+      ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
+      : undefined
+
     return {
       id: this.id,
       type: this.type,
@@ -429,6 +454,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
       actorFollow,
       plugin,
       peertube,
+      registration,
       createdAt: this.createdAt.toISOString(),
       updatedAt: this.updatedAt.toISOString()
     }

+ 259 - 0
server/models/user/user-registration.ts

@@ -0,0 +1,259 @@
+import { FindOptions, Op, WhereOptions } from 'sequelize'
+import {
+  AllowNull,
+  BeforeCreate,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  ForeignKey,
+  Is,
+  IsEmail,
+  Model,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import {
+  isRegistrationModerationResponseValid,
+  isRegistrationReasonValid,
+  isRegistrationStateValid
+} from '@server/helpers/custom-validators/user-registration'
+import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
+import { cryptPassword } from '@server/helpers/peertube-crypto'
+import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
+import { MRegistration, MRegistrationFormattable } from '@server/types/models'
+import { UserRegistration, UserRegistrationState } from '@shared/models'
+import { AttributesOnly } from '@shared/typescript-utils'
+import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
+import { getSort, throwIfNotValid } from '../shared'
+import { UserModel } from './user'
+
+@Table({
+  tableName: 'userRegistration',
+  indexes: [
+    {
+      fields: [ 'username' ],
+      unique: true
+    },
+    {
+      fields: [ 'email' ],
+      unique: true
+    },
+    {
+      fields: [ 'channelHandle' ],
+      unique: true
+    },
+    {
+      fields: [ 'userId' ],
+      unique: true
+    }
+  ]
+})
+export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
+
+  @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<MRegistration> {
+    return UserRegistrationModel.findByPk(id)
+  }
+
+  static loadByEmail (email: string): Promise<MRegistration> {
+    const query = {
+      where: { email }
+    }
+
+    return UserRegistrationModel.findOne(query)
+  }
+
+  static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
+    const query = {
+      where: {
+        [Op.or]: [
+          { email: emailOrUsername },
+          { username: emailOrUsername }
+        ]
+      }
+    }
+
+    return UserRegistrationModel.findOne(query)
+  }
+
+  static loadByEmailOrHandle (options: {
+    email: string
+    username: string
+    channelHandle?: string
+  }): Promise<MRegistration> {
+    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<MRegistrationFormattable>(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
+    }
+  }
+}

+ 9 - 8
server/models/user/user.ts

@@ -441,16 +441,17 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
   })
   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

+ 2 - 0
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
 

+ 1 - 0
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'

+ 7 - 2
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<ApplicationModel, 'latestPeerTubeVersion'>
+
+  export type UserRegistrationInclude =
+    Pick<UserRegistrationModel, 'id' | 'username'>
 }
 
 // ############################################################################
 
 export type MUserNotification =
   Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
-  'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'>
+  '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>

+ 15 - 0
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<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
+
+// ############################################################################
+
+export type MRegistration = Omit<UserRegistrationModel, 'User'>
+
+// ############################################################################
+
+export type MRegistrationFormattable =
+  MRegistration &
+  Use<'User', MUserId>

+ 2 - 1
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]: []

+ 7 - 0
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

+ 1 - 0
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
   }

+ 1 - 0
shared/models/server/server-config.model.ts

@@ -131,6 +131,7 @@ export interface ServerConfig {
     allowed: boolean
     allowedForCurrentIP: boolean
     requiresEmailVerification: boolean
+    requiresApproval: boolean
     minimumAge: number
   }
 

+ 8 - 2
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'
 }

+ 1 - 1
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'

+ 5 - 0
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'

+ 0 - 0
shared/models/users/user-register.model.ts → shared/models/users/registration/user-register.model.ts


+ 5 - 0
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
+}

+ 5 - 0
shared/models/users/registration/user-registration-state.model.ts

@@ -0,0 +1,5 @@
+export const enum UserRegistrationState {
+  PENDING = 1,
+  REJECTED = 2,
+  ACCEPTED = 3
+}

+ 3 - 0
shared/models/users/registration/user-registration-update-state.model.ts

@@ -0,0 +1,3 @@
+export interface UserRegistrationUpdateState {
+  moderationResponse: string
+}

+ 29 - 0
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
+  }
+}

+ 8 - 1
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
 }

+ 3 - 1
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
 }