aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/config.ts1
-rw-r--r--server/controllers/api/users/email-verification.ts72
-rw-r--r--server/controllers/api/users/index.ts99
-rw-r--r--server/controllers/api/users/registrations.ts236
-rw-r--r--server/helpers/custom-validators/user-registration.ts25
-rw-r--r--server/initializers/checker-after-init.ts5
-rw-r--r--server/initializers/checker-before-init.ts2
-rw-r--r--server/initializers/config.ts1
-rw-r--r--server/initializers/constants.ts20
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/initializers/migrations/0750-user-registration.ts58
-rw-r--r--server/lib/auth/oauth.ts29
-rw-r--r--server/lib/emailer.ts54
-rw-r--r--server/lib/emails/common/base.pug12
-rw-r--r--server/lib/emails/user-registration-request-accepted/html.pug10
-rw-r--r--server/lib/emails/user-registration-request-rejected/html.pug9
-rw-r--r--server/lib/emails/user-registration-request/html.pug9
-rw-r--r--server/lib/emails/verify-email/html.pug26
-rw-r--r--server/lib/notifier/notifier.ts19
-rw-r--r--server/lib/notifier/shared/instance/direct-registration-for-moderators.ts (renamed from server/lib/notifier/shared/instance/registration-for-moderators.ts)4
-rw-r--r--server/lib/notifier/shared/instance/index.ts3
-rw-r--r--server/lib/notifier/shared/instance/registration-request-for-moderators.ts48
-rw-r--r--server/lib/redis.ts30
-rw-r--r--server/lib/server-config-manager.ts12
-rw-r--r--server/lib/signup.ts15
-rw-r--r--server/lib/user.ts38
-rw-r--r--server/middlewares/validators/config.ts1
-rw-r--r--server/middlewares/validators/index.ts2
-rw-r--r--server/middlewares/validators/shared/user-registrations.ts60
-rw-r--r--server/middlewares/validators/shared/users.ts4
-rw-r--r--server/middlewares/validators/sort.ts95
-rw-r--r--server/middlewares/validators/user-email-verification.ts94
-rw-r--r--server/middlewares/validators/user-registrations.ts203
-rw-r--r--server/middlewares/validators/users.ts151
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts130
-rw-r--r--server/models/user/user-notification.ts26
-rw-r--r--server/models/user/user-registration.ts259
-rw-r--r--server/models/user/user.ts17
-rw-r--r--server/types/express.d.ts2
-rw-r--r--server/types/models/user/index.ts1
-rw-r--r--server/types/models/user/user-notification.ts9
-rw-r--r--server/types/models/user/user-registration.ts15
42 files changed, 1470 insertions, 442 deletions
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index f0fb43071..86434f382 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -193,6 +193,7 @@ function customConfig (): CustomConfig {
193 signup: { 193 signup: {
194 enabled: CONFIG.SIGNUP.ENABLED, 194 enabled: CONFIG.SIGNUP.ENABLED,
195 limit: CONFIG.SIGNUP.LIMIT, 195 limit: CONFIG.SIGNUP.LIMIT,
196 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
196 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION, 197 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
197 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE 198 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
198 }, 199 },
diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts
new file mode 100644
index 000000000..230aaa9af
--- /dev/null
+++ b/server/controllers/api/users/email-verification.ts
@@ -0,0 +1,72 @@
1import express from 'express'
2import { HttpStatusCode } from '@shared/models'
3import { CONFIG } from '../../../initializers/config'
4import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
5import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
6import {
7 registrationVerifyEmailValidator,
8 usersAskSendVerifyEmailValidator,
9 usersVerifyEmailValidator
10} from '../../../middlewares/validators'
11
12const askSendEmailLimiter = buildRateLimiter({
13 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
14 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
15})
16
17const emailVerificationRouter = express.Router()
18
19emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
20 askSendEmailLimiter,
21 asyncMiddleware(usersAskSendVerifyEmailValidator),
22 asyncMiddleware(reSendVerifyUserEmail)
23)
24
25emailVerificationRouter.post('/:id/verify-email',
26 asyncMiddleware(usersVerifyEmailValidator),
27 asyncMiddleware(verifyUserEmail)
28)
29
30emailVerificationRouter.post('/registrations/:registrationId/verify-email',
31 asyncMiddleware(registrationVerifyEmailValidator),
32 asyncMiddleware(verifyRegistrationEmail)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 emailVerificationRouter
39}
40
41async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
42 const user = res.locals.user
43 const registration = res.locals.userRegistration
44
45 if (user) await sendVerifyUserEmail(user)
46 else if (registration) await sendVerifyRegistrationEmail(registration)
47
48 return res.status(HttpStatusCode.NO_CONTENT_204).end()
49}
50
51async function verifyUserEmail (req: express.Request, res: express.Response) {
52 const user = res.locals.user
53 user.emailVerified = true
54
55 if (req.body.isPendingEmail === true) {
56 user.email = user.pendingEmail
57 user.pendingEmail = null
58 }
59
60 await user.save()
61
62 return res.status(HttpStatusCode.NO_CONTENT_204).end()
63}
64
65async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
66 const registration = res.locals.userRegistration
67 registration.emailVerified = true
68
69 await registration.save()
70
71 return res.status(HttpStatusCode.NO_CONTENT_204).end()
72}
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index a8677a1d3..5a5a12e82 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -4,26 +4,21 @@ import { Hooks } from '@server/lib/plugins/hooks'
4import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 4import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
5import { MUserAccountDefault } from '@server/types/models' 5import { MUserAccountDefault } from '@server/types/models'
6import { pick } from '@shared/core-utils' 6import { pick } from '@shared/core-utils'
7import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' 7import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
9import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' 10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
11import { CONFIG } from '../../../initializers/config'
12import { WEBSERVER } from '../../../initializers/constants' 11import { WEBSERVER } from '../../../initializers/constants'
13import { sequelizeTypescript } from '../../../initializers/database' 12import { sequelizeTypescript } from '../../../initializers/database'
14import { Emailer } from '../../../lib/emailer' 13import { Emailer } from '../../../lib/emailer'
15import { Notifier } from '../../../lib/notifier'
16import { Redis } from '../../../lib/redis' 14import { Redis } from '../../../lib/redis'
17import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' 15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
18import { 16import {
19 adminUsersSortValidator, 17 adminUsersSortValidator,
20 asyncMiddleware, 18 asyncMiddleware,
21 asyncRetryTransactionMiddleware, 19 asyncRetryTransactionMiddleware,
22 authenticate, 20 authenticate,
23 buildRateLimiter,
24 ensureUserHasRight, 21 ensureUserHasRight,
25 ensureUserRegistrationAllowed,
26 ensureUserRegistrationAllowedForIP,
27 paginationValidator, 22 paginationValidator,
28 setDefaultPagination, 23 setDefaultPagination,
29 setDefaultSort, 24 setDefaultSort,
@@ -31,19 +26,17 @@ import {
31 usersAddValidator, 26 usersAddValidator,
32 usersGetValidator, 27 usersGetValidator,
33 usersListValidator, 28 usersListValidator,
34 usersRegisterValidator,
35 usersRemoveValidator, 29 usersRemoveValidator,
36 usersUpdateValidator 30 usersUpdateValidator
37} from '../../../middlewares' 31} from '../../../middlewares'
38import { 32import {
39 ensureCanModerateUser, 33 ensureCanModerateUser,
40 usersAskResetPasswordValidator, 34 usersAskResetPasswordValidator,
41 usersAskSendVerifyEmailValidator,
42 usersBlockingValidator, 35 usersBlockingValidator,
43 usersResetPasswordValidator, 36 usersResetPasswordValidator
44 usersVerifyEmailValidator
45} from '../../../middlewares/validators' 37} from '../../../middlewares/validators'
46import { UserModel } from '../../../models/user/user' 38import { UserModel } from '../../../models/user/user'
39import { emailVerificationRouter } from './email-verification'
47import { meRouter } from './me' 40import { meRouter } from './me'
48import { myAbusesRouter } from './my-abuses' 41import { myAbusesRouter } from './my-abuses'
49import { myBlocklistRouter } from './my-blocklist' 42import { myBlocklistRouter } from './my-blocklist'
@@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history'
51import { myNotificationsRouter } from './my-notifications' 44import { myNotificationsRouter } from './my-notifications'
52import { mySubscriptionsRouter } from './my-subscriptions' 45import { mySubscriptionsRouter } from './my-subscriptions'
53import { myVideoPlaylistsRouter } from './my-video-playlists' 46import { myVideoPlaylistsRouter } from './my-video-playlists'
47import { registrationsRouter } from './registrations'
54import { twoFactorRouter } from './two-factor' 48import { twoFactorRouter } from './two-factor'
55 49
56const auditLogger = auditLoggerFactory('users') 50const auditLogger = auditLoggerFactory('users')
57 51
58const signupRateLimiter = buildRateLimiter({
59 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
60 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
61 skipFailedRequests: true
62})
63
64const askSendEmailLimiter = buildRateLimiter({
65 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
66 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
67})
68
69const usersRouter = express.Router() 52const usersRouter = express.Router()
53usersRouter.use('/', emailVerificationRouter)
54usersRouter.use('/', registrationsRouter)
70usersRouter.use('/', twoFactorRouter) 55usersRouter.use('/', twoFactorRouter)
71usersRouter.use('/', tokensRouter) 56usersRouter.use('/', tokensRouter)
72usersRouter.use('/', myNotificationsRouter) 57usersRouter.use('/', myNotificationsRouter)
@@ -122,14 +107,6 @@ usersRouter.post('/',
122 asyncRetryTransactionMiddleware(createUser) 107 asyncRetryTransactionMiddleware(createUser)
123) 108)
124 109
125usersRouter.post('/register',
126 signupRateLimiter,
127 asyncMiddleware(ensureUserRegistrationAllowed),
128 ensureUserRegistrationAllowedForIP,
129 asyncMiddleware(usersRegisterValidator),
130 asyncRetryTransactionMiddleware(registerUser)
131)
132
133usersRouter.put('/:id', 110usersRouter.put('/:id',
134 authenticate, 111 authenticate,
135 ensureUserHasRight(UserRight.MANAGE_USERS), 112 ensureUserHasRight(UserRight.MANAGE_USERS),
@@ -156,17 +133,6 @@ usersRouter.post('/:id/reset-password',
156 asyncMiddleware(resetUserPassword) 133 asyncMiddleware(resetUserPassword)
157) 134)
158 135
159usersRouter.post('/ask-send-verify-email',
160 askSendEmailLimiter,
161 asyncMiddleware(usersAskSendVerifyEmailValidator),
162 asyncMiddleware(reSendVerifyUserEmail)
163)
164
165usersRouter.post('/:id/verify-email',
166 asyncMiddleware(usersVerifyEmailValidator),
167 asyncMiddleware(verifyUserEmail)
168)
169
170// --------------------------------------------------------------------------- 136// ---------------------------------------------------------------------------
171 137
172export { 138export {
@@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) {
218 }) 184 })
219} 185}
220 186
221async function registerUser (req: express.Request, res: express.Response) {
222 const body: UserRegister = req.body
223
224 const userToCreate = buildUser({
225 ...pick(body, [ 'username', 'password', 'email' ]),
226
227 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
228 })
229
230 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
231 userToCreate,
232 userDisplayName: body.displayName || undefined,
233 channelNames: body.channel
234 })
235
236 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
237 logger.info('User %s with its channel and account registered.', body.username)
238
239 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
240 await sendVerifyUserEmail(user)
241 }
242
243 Notifier.Instance.notifyOnNewUserRegistration(user)
244
245 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
246
247 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
248}
249
250async function unblockUser (req: express.Request, res: express.Response) { 187async function unblockUser (req: express.Request, res: express.Response) {
251 const user = res.locals.user 188 const user = res.locals.user
252 189
@@ -360,28 +297,6 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
360 return res.status(HttpStatusCode.NO_CONTENT_204).end() 297 return res.status(HttpStatusCode.NO_CONTENT_204).end()
361} 298}
362 299
363async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
364 const user = res.locals.user
365
366 await sendVerifyUserEmail(user)
367
368 return res.status(HttpStatusCode.NO_CONTENT_204).end()
369}
370
371async function verifyUserEmail (req: express.Request, res: express.Response) {
372 const user = res.locals.user
373 user.emailVerified = true
374
375 if (req.body.isPendingEmail === true) {
376 user.email = user.pendingEmail
377 user.pendingEmail = null
378 }
379
380 await user.save()
381
382 return res.status(HttpStatusCode.NO_CONTENT_204).end()
383}
384
385async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) { 300async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
386 const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) 301 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
387 302
diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts
new file mode 100644
index 000000000..3d4e0aa18
--- /dev/null
+++ b/server/controllers/api/users/registrations.ts
@@ -0,0 +1,236 @@
1import express from 'express'
2import { Emailer } from '@server/lib/emailer'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { UserRegistrationModel } from '@server/models/user/user-registration'
5import { pick } from '@shared/core-utils'
6import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState, UserRight } from '@shared/models'
7import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
8import { logger } from '../../../helpers/logger'
9import { CONFIG } from '../../../initializers/config'
10import { Notifier } from '../../../lib/notifier'
11import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
12import {
13 acceptOrRejectRegistrationValidator,
14 asyncMiddleware,
15 asyncRetryTransactionMiddleware,
16 authenticate,
17 buildRateLimiter,
18 ensureUserHasRight,
19 ensureUserRegistrationAllowedFactory,
20 ensureUserRegistrationAllowedForIP,
21 getRegistrationValidator,
22 listRegistrationsValidator,
23 paginationValidator,
24 setDefaultPagination,
25 setDefaultSort,
26 userRegistrationsSortValidator,
27 usersDirectRegistrationValidator,
28 usersRequestRegistrationValidator
29} from '../../../middlewares'
30
31const auditLogger = auditLoggerFactory('users')
32
33const registrationRateLimiter = buildRateLimiter({
34 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
35 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
36 skipFailedRequests: true
37})
38
39const registrationsRouter = express.Router()
40
41registrationsRouter.post('/registrations/request',
42 registrationRateLimiter,
43 asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
44 ensureUserRegistrationAllowedForIP,
45 asyncMiddleware(usersRequestRegistrationValidator),
46 asyncRetryTransactionMiddleware(requestRegistration)
47)
48
49registrationsRouter.post('/registrations/:registrationId/accept',
50 authenticate,
51 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
52 asyncMiddleware(acceptOrRejectRegistrationValidator),
53 asyncRetryTransactionMiddleware(acceptRegistration)
54)
55registrationsRouter.post('/registrations/:registrationId/reject',
56 authenticate,
57 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
58 asyncMiddleware(acceptOrRejectRegistrationValidator),
59 asyncRetryTransactionMiddleware(rejectRegistration)
60)
61
62registrationsRouter.delete('/registrations/:registrationId',
63 authenticate,
64 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
65 asyncMiddleware(getRegistrationValidator),
66 asyncRetryTransactionMiddleware(deleteRegistration)
67)
68
69registrationsRouter.get('/registrations',
70 authenticate,
71 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
72 paginationValidator,
73 userRegistrationsSortValidator,
74 setDefaultSort,
75 setDefaultPagination,
76 listRegistrationsValidator,
77 asyncMiddleware(listRegistrations)
78)
79
80registrationsRouter.post('/register',
81 registrationRateLimiter,
82 asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
83 ensureUserRegistrationAllowedForIP,
84 asyncMiddleware(usersDirectRegistrationValidator),
85 asyncRetryTransactionMiddleware(registerUser)
86)
87
88// ---------------------------------------------------------------------------
89
90export {
91 registrationsRouter
92}
93
94// ---------------------------------------------------------------------------
95
96async function requestRegistration (req: express.Request, res: express.Response) {
97 const body: UserRegistrationRequest = req.body
98
99 const registration = new UserRegistrationModel({
100 ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
101
102 accountDisplayName: body.displayName,
103 channelDisplayName: body.channel?.displayName,
104 channelHandle: body.channel?.name,
105
106 state: UserRegistrationState.PENDING,
107
108 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
109 })
110
111 await registration.save()
112
113 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
114 await sendVerifyRegistrationEmail(registration)
115 }
116
117 Notifier.Instance.notifyOnNewRegistrationRequest(registration)
118
119 Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
120
121 return res.json(registration.toFormattedJSON())
122}
123
124// ---------------------------------------------------------------------------
125
126async function acceptRegistration (req: express.Request, res: express.Response) {
127 const registration = res.locals.userRegistration
128
129 const userToCreate = buildUser({
130 username: registration.username,
131 password: registration.password,
132 email: registration.email,
133 emailVerified: registration.emailVerified
134 })
135 // We already encrypted password in registration model
136 userToCreate.skipPasswordEncryption = true
137
138 // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
139
140 const { user } = await createUserAccountAndChannelAndPlaylist({
141 userToCreate,
142 userDisplayName: registration.accountDisplayName,
143 channelNames: registration.channelHandle && registration.channelDisplayName
144 ? {
145 name: registration.channelHandle,
146 displayName: registration.channelDisplayName
147 }
148 : undefined
149 })
150
151 registration.userId = user.id
152 registration.state = UserRegistrationState.ACCEPTED
153 registration.moderationResponse = req.body.moderationResponse
154
155 await registration.save()
156
157 logger.info('Registration of %s accepted', registration.username)
158
159 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
160
161 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
162}
163
164async function rejectRegistration (req: express.Request, res: express.Response) {
165 const registration = res.locals.userRegistration
166
167 registration.state = UserRegistrationState.REJECTED
168 registration.moderationResponse = req.body.moderationResponse
169
170 await registration.save()
171
172 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
173
174 logger.info('Registration of %s rejected', registration.username)
175
176 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
177}
178
179// ---------------------------------------------------------------------------
180
181async function deleteRegistration (req: express.Request, res: express.Response) {
182 const registration = res.locals.userRegistration
183
184 await registration.destroy()
185
186 logger.info('Registration of %s deleted', registration.username)
187
188 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
189}
190
191// ---------------------------------------------------------------------------
192
193async function listRegistrations (req: express.Request, res: express.Response) {
194 const resultList = await UserRegistrationModel.listForApi({
195 start: req.query.start,
196 count: req.query.count,
197 sort: req.query.sort,
198 search: req.query.search
199 })
200
201 return res.json({
202 total: resultList.total,
203 data: resultList.data.map(d => d.toFormattedJSON())
204 })
205}
206
207// ---------------------------------------------------------------------------
208
209async function registerUser (req: express.Request, res: express.Response) {
210 const body: UserRegister = req.body
211
212 const userToCreate = buildUser({
213 ...pick(body, [ 'username', 'password', 'email' ]),
214
215 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
216 })
217
218 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
219 userToCreate,
220 userDisplayName: body.displayName || undefined,
221 channelNames: body.channel
222 })
223
224 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
225 logger.info('User %s with its channel and account registered.', body.username)
226
227 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
228 await sendVerifyUserEmail(user)
229 }
230
231 Notifier.Instance.notifyOnNewDirectRegistration(user)
232
233 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
234
235 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
236}
diff --git a/server/helpers/custom-validators/user-registration.ts b/server/helpers/custom-validators/user-registration.ts
new file mode 100644
index 000000000..9da0bb08a
--- /dev/null
+++ b/server/helpers/custom-validators/user-registration.ts
@@ -0,0 +1,25 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants'
3import { exists } from './misc'
4
5const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS
6
7function isRegistrationStateValid (value: string) {
8 return exists(value) && USER_REGISTRATION_STATES[value] !== undefined
9}
10
11function isRegistrationModerationResponseValid (value: string) {
12 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE)
13}
14
15function isRegistrationReasonValid (value: string) {
16 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE)
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 isRegistrationStateValid,
23 isRegistrationModerationResponseValid,
24 isRegistrationReasonValid
25}
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index dc46b5126..247bc2ad5 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -116,6 +116,11 @@ function checkEmailConfig () {
116 throw new Error('Emailer is disabled but you require signup email verification.') 116 throw new Error('Emailer is disabled but you require signup email verification.')
117 } 117 }
118 118
119 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_APPROVAL) {
120 // eslint-disable-next-line max-len
121 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')
122 }
123
119 if (CONFIG.CONTACT_FORM.ENABLED) { 124 if (CONFIG.CONTACT_FORM.ENABLED) {
120 logger.warn('Emailer is disabled so the contact form will not work.') 125 logger.warn('Emailer is disabled so the contact form will not work.')
121 } 126 }
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 57852241c..8b4d49180 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -28,7 +28,7 @@ function checkMissedConfig () {
28 'csp.enabled', 'csp.report_only', 'csp.report_uri', 28 'csp.enabled', 'csp.report_only', 'csp.report_uri',
29 'security.frameguard.enabled', 29 'security.frameguard.enabled',
30 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled', 30 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
31 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age', 31 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
32 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 32 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
33 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 33 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
34 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled', 34 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 28aaf36a9..9685e7bfc 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -305,6 +305,7 @@ const CONFIG = {
305 }, 305 },
306 SIGNUP: { 306 SIGNUP: {
307 get ENABLED () { return config.get<boolean>('signup.enabled') }, 307 get ENABLED () { return config.get<boolean>('signup.enabled') },
308 get REQUIRES_APPROVAL () { return config.get<boolean>('signup.requires_approval') },
308 get LIMIT () { return config.get<number>('signup.limit') }, 309 get LIMIT () { return config.get<number>('signup.limit') },
309 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') }, 310 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
310 get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') }, 311 get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 0dab524d9..2ef3da2e7 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -6,6 +6,7 @@ import { randomInt, root } from '@shared/core-utils'
6import { 6import {
7 AbuseState, 7 AbuseState,
8 JobType, 8 JobType,
9 UserRegistrationState,
9 VideoChannelSyncState, 10 VideoChannelSyncState,
10 VideoImportState, 11 VideoImportState,
11 VideoPrivacy, 12 VideoPrivacy,
@@ -25,7 +26,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
25 26
26// --------------------------------------------------------------------------- 27// ---------------------------------------------------------------------------
27 28
28const LAST_MIGRATION_VERSION = 745 29const LAST_MIGRATION_VERSION = 750
29 30
30// --------------------------------------------------------------------------- 31// ---------------------------------------------------------------------------
31 32
@@ -78,6 +79,8 @@ const SORTABLE_COLUMNS = {
78 ACCOUNT_FOLLOWERS: [ 'createdAt' ], 79 ACCOUNT_FOLLOWERS: [ 'createdAt' ],
79 CHANNEL_FOLLOWERS: [ 'createdAt' ], 80 CHANNEL_FOLLOWERS: [ 'createdAt' ],
80 81
82 USER_REGISTRATIONS: [ 'createdAt', 'state' ],
83
81 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], 84 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
82 85
83 // Don't forget to update peertube-search-index with the same values 86 // Don't forget to update peertube-search-index with the same values
@@ -290,6 +293,10 @@ const CONSTRAINTS_FIELDS = {
290 ABUSE_MESSAGES: { 293 ABUSE_MESSAGES: {
291 MESSAGE: { min: 2, max: 3000 } // Length 294 MESSAGE: { min: 2, max: 3000 } // Length
292 }, 295 },
296 USER_REGISTRATIONS: {
297 REASON_MESSAGE: { min: 2, max: 3000 }, // Length
298 MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length
299 },
293 VIDEO_BLACKLIST: { 300 VIDEO_BLACKLIST: {
294 REASON: { min: 2, max: 300 } // Length 301 REASON: { min: 2, max: 300 } // Length
295 }, 302 },
@@ -516,6 +523,12 @@ const ABUSE_STATES: { [ id in AbuseState ]: string } = {
516 [AbuseState.ACCEPTED]: 'Accepted' 523 [AbuseState.ACCEPTED]: 'Accepted'
517} 524}
518 525
526const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = {
527 [UserRegistrationState.PENDING]: 'Pending',
528 [UserRegistrationState.REJECTED]: 'Rejected',
529 [UserRegistrationState.ACCEPTED]: 'Accepted'
530}
531
519const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { 532const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
520 [VideoPlaylistPrivacy.PUBLIC]: 'Public', 533 [VideoPlaylistPrivacy.PUBLIC]: 'Public',
521 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', 534 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
@@ -660,7 +673,7 @@ const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
660 673
661const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes 674const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
662 675
663const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes 676const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
664 677
665const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { 678const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
666 DO_NOT_LIST: 'do_not_list', 679 DO_NOT_LIST: 'do_not_list',
@@ -1069,13 +1082,14 @@ export {
1069 VIDEO_TRANSCODING_FPS, 1082 VIDEO_TRANSCODING_FPS,
1070 FFMPEG_NICE, 1083 FFMPEG_NICE,
1071 ABUSE_STATES, 1084 ABUSE_STATES,
1085 USER_REGISTRATION_STATES,
1072 LRU_CACHE, 1086 LRU_CACHE,
1073 REQUEST_TIMEOUTS, 1087 REQUEST_TIMEOUTS,
1074 MAX_LOCAL_VIEWER_WATCH_SECTIONS, 1088 MAX_LOCAL_VIEWER_WATCH_SECTIONS,
1075 USER_PASSWORD_RESET_LIFETIME, 1089 USER_PASSWORD_RESET_LIFETIME,
1076 USER_PASSWORD_CREATE_LIFETIME, 1090 USER_PASSWORD_CREATE_LIFETIME,
1077 MEMOIZE_TTL, 1091 MEMOIZE_TTL,
1078 USER_EMAIL_VERIFY_LIFETIME, 1092 EMAIL_VERIFY_LIFETIME,
1079 OVERVIEWS, 1093 OVERVIEWS,
1080 SCHEDULER_INTERVALS_MS, 1094 SCHEDULER_INTERVALS_MS,
1081 REPEAT_JOBS, 1095 REPEAT_JOBS,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index f55f40df0..96145f489 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -5,7 +5,9 @@ import { TrackerModel } from '@server/models/server/tracker'
5import { VideoTrackerModel } from '@server/models/server/video-tracker' 5import { VideoTrackerModel } from '@server/models/server/video-tracker'
6import { UserModel } from '@server/models/user/user' 6import { UserModel } from '@server/models/user/user'
7import { UserNotificationModel } from '@server/models/user/user-notification' 7import { UserNotificationModel } from '@server/models/user/user-notification'
8import { UserRegistrationModel } from '@server/models/user/user-registration'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 9import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
10import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info' 11import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 12import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
11import { VideoSourceModel } from '@server/models/video/video-source' 13import { VideoSourceModel } from '@server/models/video/video-source'
@@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
50import { VideoTagModel } from '../models/video/video-tag' 52import { VideoTagModel } from '../models/video/video-tag'
51import { VideoViewModel } from '../models/view/video-view' 53import { VideoViewModel } from '../models/view/video-view'
52import { CONFIG } from './config' 54import { CONFIG } from './config'
53import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
54 55
55require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 56require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
56 57
@@ -155,7 +156,8 @@ async function initDatabaseModels (silent: boolean) {
155 PluginModel, 156 PluginModel,
156 ActorCustomPageModel, 157 ActorCustomPageModel,
157 VideoJobInfoModel, 158 VideoJobInfoModel,
158 VideoChannelSyncModel 159 VideoChannelSyncModel,
160 UserRegistrationModel
159 ]) 161 ])
160 162
161 // Check extensions exist in the database 163 // Check extensions exist in the database
diff --git a/server/initializers/migrations/0750-user-registration.ts b/server/initializers/migrations/0750-user-registration.ts
new file mode 100644
index 000000000..15bbfd3fd
--- /dev/null
+++ b/server/initializers/migrations/0750-user-registration.ts
@@ -0,0 +1,58 @@
1
2import * as Sequelize from 'sequelize'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction
6 queryInterface: Sequelize.QueryInterface
7 sequelize: Sequelize.Sequelize
8 db: any
9}): Promise<void> {
10 {
11 const query = `
12 CREATE TABLE IF NOT EXISTS "userRegistration" (
13 "id" serial,
14 "state" integer NOT NULL,
15 "registrationReason" text NOT NULL,
16 "moderationResponse" text,
17 "password" varchar(255),
18 "username" varchar(255) NOT NULL,
19 "email" varchar(400) NOT NULL,
20 "emailVerified" boolean,
21 "accountDisplayName" varchar(255),
22 "channelHandle" varchar(255),
23 "channelDisplayName" varchar(255),
24 "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
25 "createdAt" timestamp with time zone NOT NULL,
26 "updatedAt" timestamp with time zone NOT NULL,
27 PRIMARY KEY ("id")
28 );
29 `
30 await utils.sequelize.query(query, { transaction: utils.transaction })
31 }
32
33 {
34 await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', {
35 type: Sequelize.INTEGER,
36 defaultValue: null,
37 allowNull: true,
38 references: {
39 model: 'userRegistration',
40 key: 'id'
41 },
42 onUpdate: 'CASCADE',
43 onDelete: 'SET NULL'
44 }, { transaction: utils.transaction })
45 }
46}
47
48async function down (utils: {
49 queryInterface: Sequelize.QueryInterface
50 transaction: Sequelize.Transaction
51}) {
52 await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
53}
54
55export {
56 up,
57 down
58}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index 2905c79a2..887c4f7c9 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -11,20 +11,31 @@ import OAuth2Server, {
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config' 13import { CONFIG } from '@server/initializers/config'
14import { UserRegistrationModel } from '@server/models/user/user-registration'
14import { MOAuthClient } from '@server/types/models' 15import { MOAuthClient } from '@server/types/models'
15import { sha1 } from '@shared/extra-utils' 16import { sha1 } from '@shared/extra-utils'
16import { HttpStatusCode } from '@shared/models' 17import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
17import { OTP } from '../../initializers/constants' 18import { OTP } from '../../initializers/constants'
18import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 19import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
19 20
20class MissingTwoFactorError extends Error { 21class MissingTwoFactorError extends Error {
21 code = HttpStatusCode.UNAUTHORIZED_401 22 code = HttpStatusCode.UNAUTHORIZED_401
22 name = 'missing_two_factor' 23 name = ServerErrorCode.MISSING_TWO_FACTOR
23} 24}
24 25
25class InvalidTwoFactorError extends Error { 26class InvalidTwoFactorError extends Error {
26 code = HttpStatusCode.BAD_REQUEST_400 27 code = HttpStatusCode.BAD_REQUEST_400
27 name = 'invalid_two_factor' 28 name = ServerErrorCode.INVALID_TWO_FACTOR
29}
30
31class RegistrationWaitingForApproval extends Error {
32 code = HttpStatusCode.BAD_REQUEST_400
33 name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
34}
35
36class RegistrationApprovalRejected extends Error {
37 code = HttpStatusCode.BAD_REQUEST_400
38 name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
28} 39}
29 40
30/** 41/**
@@ -128,7 +139,17 @@ async function handlePasswordGrant (options: {
128 } 139 }
129 140
130 const user = await getUser(request.body.username, request.body.password, bypassLogin) 141 const user = await getUser(request.body.username, request.body.password, bypassLogin)
131 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') 142 if (!user) {
143 const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
144
145 if (registration?.state === UserRegistrationState.REJECTED) {
146 throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
147 } else if (registration?.state === UserRegistrationState.PENDING) {
148 throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
149 }
150
151 throw new InvalidGrantError('Invalid grant: user credentials are invalid')
152 }
132 153
133 if (user.otpSecret) { 154 if (user.otpSecret) {
134 if (!request.headers[OTP.HEADER_NAME]) { 155 if (!request.headers[OTP.HEADER_NAME]) {
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 39b662eb2..f5c3e4745 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -3,13 +3,13 @@ import { merge } from 'lodash'
3import { createTransport, Transporter } from 'nodemailer' 3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path' 4import { join } from 'path'
5import { arrayify, root } from '@shared/core-utils' 5import { arrayify, root } from '@shared/core-utils'
6import { EmailPayload } from '@shared/models' 6import { EmailPayload, UserRegistrationState } from '@shared/models'
7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' 7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
8import { isTestOrDevInstance } from '../helpers/core-utils' 8import { isTestOrDevInstance } from '../helpers/core-utils'
9import { bunyanLogger, logger } from '../helpers/logger' 9import { bunyanLogger, logger } from '../helpers/logger'
10import { CONFIG, isEmailEnabled } from '../initializers/config' 10import { CONFIG, isEmailEnabled } from '../initializers/config'
11import { WEBSERVER } from '../initializers/constants' 11import { WEBSERVER } from '../initializers/constants'
12import { MUser } from '../types/models' 12import { MRegistration, MUser } from '../types/models'
13import { JobQueue } from './job-queue' 13import { JobQueue } from './job-queue'
14 14
15const Email = require('email-templates') 15const Email = require('email-templates')
@@ -62,7 +62,9 @@ class Emailer {
62 subject: 'Reset your account password', 62 subject: 'Reset your account password',
63 locals: { 63 locals: {
64 username, 64 username,
65 resetPasswordUrl 65 resetPasswordUrl,
66
67 hideNotificationPreferencesLink: true
66 } 68 }
67 } 69 }
68 70
@@ -76,21 +78,33 @@ class Emailer {
76 subject: 'Create your account password', 78 subject: 'Create your account password',
77 locals: { 79 locals: {
78 username, 80 username,
79 createPasswordUrl 81 createPasswordUrl,
82
83 hideNotificationPreferencesLink: true
80 } 84 }
81 } 85 }
82 86
83 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload }) 87 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
84 } 88 }
85 89
86 addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) { 90 addVerifyEmailJob (options: {
91 username: string
92 isRegistrationRequest: boolean
93 to: string
94 verifyEmailUrl: string
95 }) {
96 const { username, isRegistrationRequest, to, verifyEmailUrl } = options
97
87 const emailPayload: EmailPayload = { 98 const emailPayload: EmailPayload = {
88 template: 'verify-email', 99 template: 'verify-email',
89 to: [ to ], 100 to: [ to ],
90 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`, 101 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
91 locals: { 102 locals: {
92 username, 103 username,
93 verifyEmailUrl 104 verifyEmailUrl,
105 isRegistrationRequest,
106
107 hideNotificationPreferencesLink: true
94 } 108 }
95 } 109 }
96 110
@@ -123,7 +137,33 @@ class Emailer {
123 body, 137 body,
124 138
125 // There are not notification preferences for the contact form 139 // There are not notification preferences for the contact form
126 hideNotificationPreferences: true 140 hideNotificationPreferencesLink: true
141 }
142 }
143
144 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
145 }
146
147 addUserRegistrationRequestProcessedJob (registration: MRegistration) {
148 let template: string
149 let subject: string
150 if (registration.state === UserRegistrationState.ACCEPTED) {
151 template = 'user-registration-request-accepted'
152 subject = `Your registration request for ${registration.username} has been accepted`
153 } else {
154 template = 'user-registration-request-rejected'
155 subject = `Your registration request for ${registration.username} has been rejected`
156 }
157
158 const to = registration.email
159 const emailPayload: EmailPayload = {
160 to: [ to ],
161 template,
162 subject,
163 locals: {
164 username: registration.username,
165 moderationResponse: registration.moderationResponse,
166 loginLink: WEBSERVER.URL + '/login'
127 } 167 }
128 } 168 }
129 169
diff --git a/server/lib/emails/common/base.pug b/server/lib/emails/common/base.pug
index 6da5648e4..41e94564d 100644
--- a/server/lib/emails/common/base.pug
+++ b/server/lib/emails/common/base.pug
@@ -222,19 +222,9 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;') 222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
223 br 223 br
224 //- Clear Spacer : END 224 //- Clear Spacer : END
225 //- 1 Column Text : BEGIN
226 if username
227 tr
228 td(style='background-color: #cccccc;')
229 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
230 tr
231 td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
232 p(style='margin: 0;')
233 | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}.
234 //- 1 Column Text : END
235 //- Email Body : END 225 //- Email Body : END
236 //- Email Footer : BEGIN 226 //- Email Footer : BEGIN
237 unless hideNotificationPreferences 227 unless hideNotificationPreferencesLink
238 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') 228 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
239 tr 229 tr
240 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') 230 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
diff --git a/server/lib/emails/user-registration-request-accepted/html.pug b/server/lib/emails/user-registration-request-accepted/html.pug
new file mode 100644
index 000000000..7a52c3fe1
--- /dev/null
+++ b/server/lib/emails/user-registration-request-accepted/html.pug
@@ -0,0 +1,10 @@
1extends ../common/greetings
2
3block title
4 | Congratulation #{username}, your registration request has been accepted!
5
6block content
7 p Your registration request has been accepted.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
10 p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}]
diff --git a/server/lib/emails/user-registration-request-rejected/html.pug b/server/lib/emails/user-registration-request-rejected/html.pug
new file mode 100644
index 000000000..ec0aa8dfe
--- /dev/null
+++ b/server/lib/emails/user-registration-request-rejected/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | Registration request of your account #{username} has rejected
5
6block content
7 p Your registration request has been rejected.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
diff --git a/server/lib/emails/user-registration-request/html.pug b/server/lib/emails/user-registration-request/html.pug
new file mode 100644
index 000000000..64898f3f2
--- /dev/null
+++ b/server/lib/emails/user-registration-request/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | A new user wants to register
5
6block content
7 p User #{registration.username} wants to register on your PeerTube instance with the following reason:
8 blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
9 p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
diff --git a/server/lib/emails/verify-email/html.pug b/server/lib/emails/verify-email/html.pug
index be9dde21b..19ef65f75 100644
--- a/server/lib/emails/verify-email/html.pug
+++ b/server/lib/emails/verify-email/html.pug
@@ -1,17 +1,19 @@
1extends ../common/greetings 1extends ../common/greetings
2 2
3block title 3block title
4 | Account verification 4 | Email verification
5 5
6block content 6block content
7 p Welcome to #{instanceName}! 7 if isRegistrationRequest
8 p. 8 p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
9 You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}]. 9 else
10 Your username there is: #{username}. 10 p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
11 p. 11
12 To start using your account you must verify your email first! 12 if isRegistrationRequest
13 Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you. 13 p To complete your registration request you must verify your email first!
14 p. 14 else
15 If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}] 15 p To start using your account you must verify your email first!
16 p. 16
17 If you are not the person who initiated this request, please ignore this email. 17 p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
18 p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
19 p If you are not the person who initiated this request, please ignore this email.
diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts
index 66cfc31c4..920c55df0 100644
--- a/server/lib/notifier/notifier.ts
+++ b/server/lib/notifier/notifier.ts
@@ -1,4 +1,4 @@
1import { MUser, MUserDefault } from '@server/types/models/user' 1import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' 2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
3import { UserNotificationSettingValue } from '../../../shared/models/users' 3import { UserNotificationSettingValue } from '../../../shared/models/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
@@ -13,6 +13,7 @@ import {
13 AbuseStateChangeForReporter, 13 AbuseStateChangeForReporter,
14 AutoFollowForInstance, 14 AutoFollowForInstance,
15 CommentMention, 15 CommentMention,
16 DirectRegistrationForModerators,
16 FollowForInstance, 17 FollowForInstance,
17 FollowForUser, 18 FollowForUser,
18 ImportFinishedForOwner, 19 ImportFinishedForOwner,
@@ -30,7 +31,7 @@ import {
30 OwnedPublicationAfterAutoUnblacklist, 31 OwnedPublicationAfterAutoUnblacklist,
31 OwnedPublicationAfterScheduleUpdate, 32 OwnedPublicationAfterScheduleUpdate,
32 OwnedPublicationAfterTranscoding, 33 OwnedPublicationAfterTranscoding,
33 RegistrationForModerators, 34 RegistrationRequestForModerators,
34 StudioEditionFinishedForOwner, 35 StudioEditionFinishedForOwner,
35 UnblacklistForOwner 36 UnblacklistForOwner
36} from './shared' 37} from './shared'
@@ -47,7 +48,8 @@ class Notifier {
47 newBlacklist: [ NewBlacklistForOwner ], 48 newBlacklist: [ NewBlacklistForOwner ],
48 unblacklist: [ UnblacklistForOwner ], 49 unblacklist: [ UnblacklistForOwner ],
49 importFinished: [ ImportFinishedForOwner ], 50 importFinished: [ ImportFinishedForOwner ],
50 userRegistration: [ RegistrationForModerators ], 51 directRegistration: [ DirectRegistrationForModerators ],
52 registrationRequest: [ RegistrationRequestForModerators ],
51 userFollow: [ FollowForUser ], 53 userFollow: [ FollowForUser ],
52 instanceFollow: [ FollowForInstance ], 54 instanceFollow: [ FollowForInstance ],
53 autoInstanceFollow: [ AutoFollowForInstance ], 55 autoInstanceFollow: [ AutoFollowForInstance ],
@@ -138,13 +140,20 @@ class Notifier {
138 }) 140 })
139 } 141 }
140 142
141 notifyOnNewUserRegistration (user: MUserDefault): void { 143 notifyOnNewDirectRegistration (user: MUserDefault): void {
142 const models = this.notificationModels.userRegistration 144 const models = this.notificationModels.directRegistration
143 145
144 this.sendNotifications(models, user) 146 this.sendNotifications(models, user)
145 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err })) 147 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
146 } 148 }
147 149
150 notifyOnNewRegistrationRequest (registration: MRegistration): void {
151 const models = this.notificationModels.registrationRequest
152
153 this.sendNotifications(models, registration)
154 .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
155 }
156
148 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void { 157 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
149 const models = this.notificationModels.userFollow 158 const models = this.notificationModels.userFollow
150 159
diff --git a/server/lib/notifier/shared/instance/registration-for-moderators.ts b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
index e92467424..5044f2068 100644
--- a/server/lib/notifier/shared/instance/registration-for-moderators.ts
+++ b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
@@ -6,7 +6,7 @@ import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi
6import { UserNotificationType, UserRight } from '@shared/models' 6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification' 7import { AbstractNotification } from '../common/abstract-notification'
8 8
9export class RegistrationForModerators extends AbstractNotification <MUserDefault> { 9export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
10 private moderators: MUserDefault[] 10 private moderators: MUserDefault[]
11 11
12 async prepare () { 12 async prepare () {
@@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification <MUserDefaul
40 return { 40 return {
41 template: 'user-registered', 41 template: 'user-registered',
42 to, 42 to,
43 subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`, 43 subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
44 locals: { 44 locals: {
45 user: this.payload 45 user: this.payload
46 } 46 }
diff --git a/server/lib/notifier/shared/instance/index.ts b/server/lib/notifier/shared/instance/index.ts
index c3bb22aec..8c75a8ee9 100644
--- a/server/lib/notifier/shared/instance/index.ts
+++ b/server/lib/notifier/shared/instance/index.ts
@@ -1,3 +1,4 @@
1export * from './new-peertube-version-for-admins' 1export * from './new-peertube-version-for-admins'
2export * from './new-plugin-version-for-admins' 2export * from './new-plugin-version-for-admins'
3export * from './registration-for-moderators' 3export * from './direct-registration-for-moderators'
4export * from './registration-request-for-moderators'
diff --git a/server/lib/notifier/shared/instance/registration-request-for-moderators.ts b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
new file mode 100644
index 000000000..79920245a
--- /dev/null
+++ b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
@@ -0,0 +1,48 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
13 }
14
15 log () {
16 logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.newUserRegistration
21 }
22
23 getTargetUsers () {
24 return this.moderators
25 }
26
27 createNotification (user: MUserWithNotificationSetting) {
28 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
29 type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
30 userId: user.id,
31 userRegistrationId: this.payload.id
32 })
33 notification.UserRegistration = this.payload
34
35 return notification
36 }
37
38 createEmail (to: string) {
39 return {
40 template: 'user-registration-request',
41 to,
42 subject: `A new user wants to register: ${this.payload.username}`,
43 locals: {
44 registration: this.payload
45 }
46 }
47 }
48}
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 451ddd0b6..3706d2228 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -9,7 +9,7 @@ import {
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, 11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
12 USER_EMAIL_VERIFY_LIFETIME, 12 EMAIL_VERIFY_LIFETIME,
13 USER_PASSWORD_CREATE_LIFETIME, 13 USER_PASSWORD_CREATE_LIFETIME,
14 USER_PASSWORD_RESET_LIFETIME, 14 USER_PASSWORD_RESET_LIFETIME,
15 VIEW_LIFETIME, 15 VIEW_LIFETIME,
@@ -124,16 +124,28 @@ class Redis {
124 124
125 /* ************ Email verification ************ */ 125 /* ************ Email verification ************ */
126 126
127 async setVerifyEmailVerificationString (userId: number) { 127 async setUserVerifyEmailVerificationString (userId: number) {
128 const generatedString = await generateRandomString(32) 128 const generatedString = await generateRandomString(32)
129 129
130 await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) 130 await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
131 131
132 return generatedString 132 return generatedString
133 } 133 }
134 134
135 async getVerifyEmailLink (userId: number) { 135 async getUserVerifyEmailLink (userId: number) {
136 return this.getValue(this.generateVerifyEmailKey(userId)) 136 return this.getValue(this.generateUserVerifyEmailKey(userId))
137 }
138
139 async setRegistrationVerifyEmailVerificationString (registrationId: number) {
140 const generatedString = await generateRandomString(32)
141
142 await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
143
144 return generatedString
145 }
146
147 async getRegistrationVerifyEmailLink (registrationId: number) {
148 return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
137 } 149 }
138 150
139 /* ************ Contact form per IP ************ */ 151 /* ************ Contact form per IP ************ */
@@ -346,8 +358,12 @@ class Redis {
346 return 'two-factor-request-' + userId + '-' + token 358 return 'two-factor-request-' + userId + '-' + token
347 } 359 }
348 360
349 private generateVerifyEmailKey (userId: number) { 361 private generateUserVerifyEmailKey (userId: number) {
350 return 'verify-email-' + userId 362 return 'verify-email-user-' + userId
363 }
364
365 private generateRegistrationVerifyEmailKey (registrationId: number) {
366 return 'verify-email-registration-' + registrationId
351 } 367 }
352 368
353 private generateIPViewKey (ip: string, videoUUID: string) { 369 private generateIPViewKey (ip: string, videoUUID: string) {
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 78a9546ae..e87e2854f 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -261,10 +261,17 @@ class ServerConfigManager {
261 async getServerConfig (ip?: string): Promise<ServerConfig> { 261 async getServerConfig (ip?: string): Promise<ServerConfig> {
262 const { allowed } = await Hooks.wrapPromiseFun( 262 const { allowed } = await Hooks.wrapPromiseFun(
263 isSignupAllowed, 263 isSignupAllowed,
264
264 { 265 {
265 ip 266 ip,
267 signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
268 ? 'request-registration'
269 : 'direct-registration'
266 }, 270 },
267 'filter:api.user.signup.allowed.result' 271
272 CONFIG.SIGNUP.REQUIRES_APPROVAL
273 ? 'filter:api.user.request-signup.allowed.result'
274 : 'filter:api.user.signup.allowed.result'
268 ) 275 )
269 276
270 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) 277 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
@@ -273,6 +280,7 @@ class ServerConfigManager {
273 allowed, 280 allowed,
274 allowedForCurrentIP, 281 allowedForCurrentIP,
275 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE, 282 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
283 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
276 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 284 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
277 } 285 }
278 286
diff --git a/server/lib/signup.ts b/server/lib/signup.ts
index f094531eb..f19232621 100644
--- a/server/lib/signup.ts
+++ b/server/lib/signup.ts
@@ -4,11 +4,24 @@ import { UserModel } from '../models/user/user'
4 4
5const isCidr = require('is-cidr') 5const isCidr = require('is-cidr')
6 6
7async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> { 7export type SignupMode = 'direct-registration' | 'request-registration'
8
9async function isSignupAllowed (options: {
10 signupMode: SignupMode
11
12 ip: string // For plugins
13 body?: any
14}): Promise<{ allowed: boolean, errorMessage?: string }> {
15 const { signupMode } = options
16
8 if (CONFIG.SIGNUP.ENABLED === false) { 17 if (CONFIG.SIGNUP.ENABLED === false) {
9 return { allowed: false } 18 return { allowed: false }
10 } 19 }
11 20
21 if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
22 return { allowed: false }
23 }
24
12 // No limit and signup is enabled 25 // No limit and signup is enabled
13 if (CONFIG.SIGNUP.LIMIT === -1) { 26 if (CONFIG.SIGNUP.LIMIT === -1) {
14 return { allowed: true } 27 return { allowed: true }
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 2e433da04..ffb57944a 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -10,7 +10,7 @@ import { sequelizeTypescript } from '../initializers/database'
10import { AccountModel } from '../models/account/account' 10import { AccountModel } from '../models/account/account'
11import { UserNotificationSettingModel } from '../models/user/user-notification-setting' 11import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
12import { MAccountDefault, MChannelActor } from '../types/models' 12import { MAccountDefault, MChannelActor } from '../types/models'
13import { MUser, MUserDefault, MUserId } from '../types/models/user' 13import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
14import { generateAndSaveActorKeys } from './activitypub/actors' 14import { generateAndSaveActorKeys } from './activitypub/actors'
15import { getLocalAccountActivityPubUrl } from './activitypub/url' 15import { getLocalAccountActivityPubUrl } from './activitypub/url'
16import { Emailer } from './emailer' 16import { Emailer } from './emailer'
@@ -97,7 +97,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
97 }) 97 })
98 userCreated.Account = accountCreated 98 userCreated.Account = accountCreated
99 99
100 const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames) 100 const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t) 101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
102 102
103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) 103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
@@ -160,15 +160,28 @@ async function createApplicationActor (applicationId: number) {
160// --------------------------------------------------------------------------- 160// ---------------------------------------------------------------------------
161 161
162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) { 162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
163 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id) 163 const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
164 let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString 164 let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
165 165
166 if (isPendingEmail) url += '&isPendingEmail=true' 166 if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
167
168 const to = isPendingEmail
169 ? user.pendingEmail
170 : user.email
167 171
168 const email = isPendingEmail ? user.pendingEmail : user.email
169 const username = user.username 172 const username = user.username
170 173
171 Emailer.Instance.addVerifyEmailJob(username, email, url) 174 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
175}
176
177async function sendVerifyRegistrationEmail (registration: MRegistration) {
178 const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
179 const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
180
181 const to = registration.email
182 const username = registration.username
183
184 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
172} 185}
173 186
174// --------------------------------------------------------------------------- 187// ---------------------------------------------------------------------------
@@ -232,7 +245,10 @@ export {
232 createApplicationActor, 245 createApplicationActor,
233 createUserAccountAndChannelAndPlaylist, 246 createUserAccountAndChannelAndPlaylist,
234 createLocalAccountWithoutKeys, 247 createLocalAccountWithoutKeys,
248
235 sendVerifyUserEmail, 249 sendVerifyUserEmail,
250 sendVerifyRegistrationEmail,
251
236 isAbleToUploadVideo, 252 isAbleToUploadVideo,
237 buildUser 253 buildUser
238} 254}
@@ -264,7 +280,13 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
264 return UserNotificationSettingModel.create(values, { transaction: t }) 280 return UserNotificationSettingModel.create(values, { transaction: t })
265} 281}
266 282
267async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) { 283async function buildChannelAttributes (options: {
284 user: MUser
285 transaction?: Transaction
286 channelNames?: ChannelNames
287}) {
288 const { user, transaction, channelNames } = options
289
268 if (channelNames) return channelNames 290 if (channelNames) return channelNames
269 291
270 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction) 292 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index 3a7daa573..c2dbfadb7 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -29,6 +29,7 @@ const customConfigUpdateValidator = [
29 body('signup.enabled').isBoolean(), 29 body('signup.enabled').isBoolean(),
30 body('signup.limit').isInt(), 30 body('signup.limit').isInt(),
31 body('signup.requiresEmailVerification').isBoolean(), 31 body('signup.requiresEmailVerification').isBoolean(),
32 body('signup.requiresApproval').isBoolean(),
32 body('signup.minimumAge').isInt(), 33 body('signup.minimumAge').isInt(),
33 34
34 body('admin.email').isEmail(), 35 body('admin.email').isEmail(),
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 9bc8887ff..1d0964667 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -21,8 +21,10 @@ export * from './server'
21export * from './sort' 21export * from './sort'
22export * from './static' 22export * from './static'
23export * from './themes' 23export * from './themes'
24export * from './user-email-verification'
24export * from './user-history' 25export * from './user-history'
25export * from './user-notifications' 26export * from './user-notifications'
27export * from './user-registrations'
26export * from './user-subscriptions' 28export * from './user-subscriptions'
27export * from './users' 29export * from './users'
28export * from './videos' 30export * from './videos'
diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts
new file mode 100644
index 000000000..dbc7dda06
--- /dev/null
+++ b/server/middlewares/validators/shared/user-registrations.ts
@@ -0,0 +1,60 @@
1import express from 'express'
2import { UserRegistrationModel } from '@server/models/user/user-registration'
3import { MRegistration } from '@server/types/models'
4import { forceNumber, pick } from '@shared/core-utils'
5import { HttpStatusCode } from '@shared/models'
6
7function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
8 const id = forceNumber(idArg)
9 return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
10}
11
12function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) {
13 return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse)
14}
15
16async function checkRegistrationHandlesDoNotAlreadyExist (options: {
17 username: string
18 channelHandle: string
19 email: string
20 res: express.Response
21}) {
22 const { res } = options
23
24 const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ]))
25
26 if (registration) {
27 res.fail({
28 status: HttpStatusCode.CONFLICT_409,
29 message: 'Registration with this username, channel name or email already exists.'
30 })
31 return false
32 }
33
34 return true
35}
36
37async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
38 const registration = await finder()
39
40 if (!registration) {
41 if (abortResponse === true) {
42 res.fail({
43 status: HttpStatusCode.NOT_FOUND_404,
44 message: 'User not found'
45 })
46 }
47
48 return false
49 }
50
51 res.locals.userRegistration = registration
52 return true
53}
54
55export {
56 checkRegistrationIdExist,
57 checkRegistrationEmailExist,
58 checkRegistrationHandlesDoNotAlreadyExist,
59 checkRegistrationExist
60}
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
index b8f1436d3..030adc9f7 100644
--- a/server/middlewares/validators/shared/users.ts
+++ b/server/middlewares/validators/shared/users.ts
@@ -14,7 +14,7 @@ function checkUserEmailExist (email: string, res: express.Response, abortRespons
14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) 14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
15} 15}
16 16
17async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { 17async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
18 const user = await UserModel.loadByUsernameOrEmail(username, email) 18 const user = await UserModel.loadByUsernameOrEmail(username, email)
19 19
20 if (user) { 20 if (user) {
@@ -58,6 +58,6 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
58export { 58export {
59 checkUserIdExist, 59 checkUserIdExist,
60 checkUserEmailExist, 60 checkUserEmailExist,
61 checkUserNameOrEmailDoesNotAlreadyExist, 61 checkUserNameOrEmailDoNotAlreadyExist,
62 checkUserExist 62 checkUserExist
63} 63}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 7d0639107..e6cc46317 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -1,9 +1,41 @@
1import express from 'express' 1import express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3
4import { SORTABLE_COLUMNS } from '../../initializers/constants' 3import { SORTABLE_COLUMNS } from '../../initializers/constants'
5import { areValidationErrors } from './shared' 4import { areValidationErrors } from './shared'
6 5
6export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
7export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
8export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
9export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
10export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
11export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
12export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
13export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
14export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
15export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
16export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
17export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
18export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
19export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
20export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
21export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
22export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
23export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
24export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
25export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
26export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
27export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
34
35export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
36
37// ---------------------------------------------------------------------------
38
7function checkSortFactory (columns: string[], tags: string[] = []) { 39function checkSortFactory (columns: string[], tags: string[] = []) {
8 return checkSort(createSortableColumns(columns), tags) 40 return checkSort(createSortableColumns(columns), tags)
9} 41}
@@ -27,64 +59,3 @@ function createSortableColumns (sortableColumns: string[]) {
27 59
28 return sortableColumns.concat(sortableColumnDesc) 60 return sortableColumns.concat(sortableColumnDesc)
29} 61}
30
31const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
32const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
33const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
34const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
35const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
36const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
37const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
38const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
39const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
40const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
41const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
42const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
43const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
44const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
45const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
46const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
47const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
48const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
49const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
50const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
51const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
52const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
53const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
54const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
55const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
56
57const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
58const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
59
60// ---------------------------------------------------------------------------
61
62export {
63 adminUsersSortValidator,
64 abusesSortValidator,
65 videoChannelsSortValidator,
66 videoImportsSortValidator,
67 videoCommentsValidator,
68 videosSearchSortValidator,
69 videosSortValidator,
70 blacklistSortValidator,
71 accountsSortValidator,
72 instanceFollowersSortValidator,
73 instanceFollowingSortValidator,
74 jobsSortValidator,
75 videoCommentThreadsSortValidator,
76 videoRatesSortValidator,
77 userSubscriptionsSortValidator,
78 availablePluginsSortValidator,
79 videoChannelsSearchSortValidator,
80 accountsBlocklistSortValidator,
81 serversBlocklistSortValidator,
82 userNotificationsSortValidator,
83 videoPlaylistsSortValidator,
84 videoRedundanciesSortValidator,
85 videoPlaylistsSearchSortValidator,
86 accountsFollowersSortValidator,
87 videoChannelsFollowersSortValidator,
88 videoChannelSyncsSortValidator,
89 pluginsSortValidator
90}
diff --git a/server/middlewares/validators/user-email-verification.ts b/server/middlewares/validators/user-email-verification.ts
new file mode 100644
index 000000000..74702a8f5
--- /dev/null
+++ b/server/middlewares/validators/user-email-verification.ts
@@ -0,0 +1,94 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { toBooleanOrNull } from '@server/helpers/custom-validators/misc'
4import { HttpStatusCode } from '@shared/models'
5import { logger } from '../../helpers/logger'
6import { Redis } from '../../lib/redis'
7import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared'
8import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations'
9
10const usersAskSendVerifyEmailValidator = [
11 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
12
13 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 if (areValidationErrors(req, res)) return
15
16 const [ userExists, registrationExists ] = await Promise.all([
17 checkUserEmailExist(req.body.email, res, false),
18 checkRegistrationEmailExist(req.body.email, res, false)
19 ])
20
21 if (!userExists && !registrationExists) {
22 logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.email)
23 // Do not leak our emails
24 return res.status(HttpStatusCode.NO_CONTENT_204).end()
25 }
26
27 if (res.locals.user?.pluginAuth) {
28 return res.fail({
29 status: HttpStatusCode.CONFLICT_409,
30 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
31 })
32 }
33
34 return next()
35 }
36]
37
38const usersVerifyEmailValidator = [
39 param('id')
40 .isInt().not().isEmpty().withMessage('Should have a valid id'),
41
42 body('verificationString')
43 .not().isEmpty().withMessage('Should have a valid verification string'),
44 body('isPendingEmail')
45 .optional()
46 .customSanitizer(toBooleanOrNull),
47
48 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
49 if (areValidationErrors(req, res)) return
50 if (!await checkUserIdExist(req.params.id, res)) return
51
52 const user = res.locals.user
53 const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
54
55 if (redisVerificationString !== req.body.verificationString) {
56 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
57 }
58
59 return next()
60 }
61]
62
63// ---------------------------------------------------------------------------
64
65const registrationVerifyEmailValidator = [
66 param('registrationId')
67 .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
68
69 body('verificationString')
70 .not().isEmpty().withMessage('Should have a valid verification string'),
71
72 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
73 if (areValidationErrors(req, res)) return
74 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
75
76 const registration = res.locals.userRegistration
77 const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
78
79 if (redisVerificationString !== req.body.verificationString) {
80 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
81 }
82
83 return next()
84 }
85]
86
87// ---------------------------------------------------------------------------
88
89export {
90 usersAskSendVerifyEmailValidator,
91 usersVerifyEmailValidator,
92
93 registrationVerifyEmailValidator
94}
diff --git a/server/middlewares/validators/user-registrations.ts b/server/middlewares/validators/user-registrations.ts
new file mode 100644
index 000000000..e263c27c5
--- /dev/null
+++ b/server/middlewares/validators/user-registrations.ts
@@ -0,0 +1,203 @@
1import express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator'
3import { exists, isIdValid } from '@server/helpers/custom-validators/misc'
4import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration'
5import { CONFIG } from '@server/initializers/config'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models'
8import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users'
9import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
10import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup'
11import { ActorModel } from '../../models/actor/actor'
12import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared'
13import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations'
14
15const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
16
17const usersRequestRegistrationValidator = [
18 ...usersCommonRegistrationValidatorFactory([
19 body('registrationReason')
20 .custom(isRegistrationReasonValid)
21 ]),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 const body: UserRegistrationRequest = req.body
25
26 if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
27 return res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Signup approval is not enabled on this instance'
30 })
31 }
32
33 const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
34 if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
35
36 return next()
37 }
38]
39
40// ---------------------------------------------------------------------------
41
42function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
43 return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
44 const allowedParams = {
45 body: req.body,
46 ip: req.ip,
47 signupMode
48 }
49
50 const allowedResult = await Hooks.wrapPromiseFun(
51 isSignupAllowed,
52 allowedParams,
53
54 signupMode === 'direct-registration'
55 ? 'filter:api.user.signup.allowed.result'
56 : 'filter:api.user.request-signup.allowed.result'
57 )
58
59 if (allowedResult.allowed === false) {
60 return res.fail({
61 status: HttpStatusCode.FORBIDDEN_403,
62 message: allowedResult.errorMessage || 'User registration is not enabled, user limit is reached or registration requires approval.'
63 })
64 }
65
66 return next()
67 }
68}
69
70const ensureUserRegistrationAllowedForIP = [
71 (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 const allowed = isSignupAllowedForCurrentIP(req.ip)
73
74 if (allowed === false) {
75 return res.fail({
76 status: HttpStatusCode.FORBIDDEN_403,
77 message: 'You are not on a network authorized for registration.'
78 })
79 }
80
81 return next()
82 }
83]
84
85// ---------------------------------------------------------------------------
86
87const acceptOrRejectRegistrationValidator = [
88 param('registrationId')
89 .custom(isIdValid),
90
91 body('moderationResponse')
92 .custom(isRegistrationModerationResponseValid),
93
94 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
95 if (areValidationErrors(req, res)) return
96 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
97
98 if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
99 return res.fail({
100 status: HttpStatusCode.CONFLICT_409,
101 message: 'This registration is already accepted or rejected.'
102 })
103 }
104
105 return next()
106 }
107]
108
109// ---------------------------------------------------------------------------
110
111const getRegistrationValidator = [
112 param('registrationId')
113 .custom(isIdValid),
114
115 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
116 if (areValidationErrors(req, res)) return
117 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
118
119 return next()
120 }
121]
122
123// ---------------------------------------------------------------------------
124
125const listRegistrationsValidator = [
126 query('search')
127 .optional()
128 .custom(exists),
129
130 (req: express.Request, res: express.Response, next: express.NextFunction) => {
131 if (areValidationErrors(req, res)) return
132
133 return next()
134 }
135]
136
137// ---------------------------------------------------------------------------
138
139export {
140 usersDirectRegistrationValidator,
141 usersRequestRegistrationValidator,
142
143 ensureUserRegistrationAllowedFactory,
144 ensureUserRegistrationAllowedForIP,
145
146 getRegistrationValidator,
147 listRegistrationsValidator,
148
149 acceptOrRejectRegistrationValidator
150}
151
152// ---------------------------------------------------------------------------
153
154function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
155 return [
156 body('username')
157 .custom(isUserUsernameValid),
158 body('password')
159 .custom(isUserPasswordValid),
160 body('email')
161 .isEmail(),
162 body('displayName')
163 .optional()
164 .custom(isUserDisplayNameValid),
165
166 body('channel.name')
167 .optional()
168 .custom(isVideoChannelUsernameValid),
169 body('channel.displayName')
170 .optional()
171 .custom(isVideoChannelDisplayNameValid),
172
173 ...additionalValidationChain,
174
175 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
176 if (areValidationErrors(req, res, { omitBodyLog: true })) return
177
178 const body: UserRegister | UserRegistrationRequest = req.body
179
180 if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
181
182 if (body.channel) {
183 if (!body.channel.name || !body.channel.displayName) {
184 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
185 }
186
187 if (body.channel.name === body.username) {
188 return res.fail({ message: 'Channel name cannot be the same as user username.' })
189 }
190
191 const existing = await ActorModel.loadLocalByName(body.channel.name)
192 if (existing) {
193 return res.fail({
194 status: HttpStatusCode.CONFLICT_409,
195 message: `Channel with name ${body.channel.name} already exists.`
196 })
197 }
198 }
199
200 return next()
201 }
202 ]
203}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 64bd9ca70..f7033f44a 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,8 +1,7 @@
1import express from 'express' 1import express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
5import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' 4import { HttpStatusCode, UserRight, UserRole } from '@shared/models'
6import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { 7import {
@@ -24,17 +23,16 @@ import {
24 isUserVideoQuotaValid, 23 isUserVideoQuotaValid,
25 isUserVideosHistoryEnabledValid 24 isUserVideosHistoryEnabledValid
26} from '../../helpers/custom-validators/users' 25} from '../../helpers/custom-validators/users'
27import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' 26import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
28import { logger } from '../../helpers/logger' 27import { logger } from '../../helpers/logger'
29import { isThemeRegistered } from '../../lib/plugins/theme-utils' 28import { isThemeRegistered } from '../../lib/plugins/theme-utils'
30import { Redis } from '../../lib/redis' 29import { Redis } from '../../lib/redis'
31import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
32import { ActorModel } from '../../models/actor/actor' 30import { ActorModel } from '../../models/actor/actor'
33import { 31import {
34 areValidationErrors, 32 areValidationErrors,
35 checkUserEmailExist, 33 checkUserEmailExist,
36 checkUserIdExist, 34 checkUserIdExist,
37 checkUserNameOrEmailDoesNotAlreadyExist, 35 checkUserNameOrEmailDoNotAlreadyExist,
38 doesVideoChannelIdExist, 36 doesVideoChannelIdExist,
39 doesVideoExist, 37 doesVideoExist,
40 isValidVideoIdParam 38 isValidVideoIdParam
@@ -81,7 +79,7 @@ const usersAddValidator = [
81 79
82 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
83 if (areValidationErrors(req, res, { omitBodyLog: true })) return 81 if (areValidationErrors(req, res, { omitBodyLog: true })) return
84 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return 82 if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
85 83
86 const authUser = res.locals.oauth.token.User 84 const authUser = res.locals.oauth.token.User
87 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { 85 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
@@ -109,51 +107,6 @@ const usersAddValidator = [
109 } 107 }
110] 108]
111 109
112const usersRegisterValidator = [
113 body('username')
114 .custom(isUserUsernameValid),
115 body('password')
116 .custom(isUserPasswordValid),
117 body('email')
118 .isEmail(),
119 body('displayName')
120 .optional()
121 .custom(isUserDisplayNameValid),
122
123 body('channel.name')
124 .optional()
125 .custom(isVideoChannelUsernameValid),
126 body('channel.displayName')
127 .optional()
128 .custom(isVideoChannelDisplayNameValid),
129
130 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
131 if (areValidationErrors(req, res, { omitBodyLog: true })) return
132 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
133
134 const body: UserRegister = req.body
135 if (body.channel) {
136 if (!body.channel.name || !body.channel.displayName) {
137 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
138 }
139
140 if (body.channel.name === body.username) {
141 return res.fail({ message: 'Channel name cannot be the same as user username.' })
142 }
143
144 const existing = await ActorModel.loadLocalByName(body.channel.name)
145 if (existing) {
146 return res.fail({
147 status: HttpStatusCode.CONFLICT_409,
148 message: `Channel with name ${body.channel.name} already exists.`
149 })
150 }
151 }
152
153 return next()
154 }
155]
156
157const usersRemoveValidator = [ 110const usersRemoveValidator = [
158 param('id') 111 param('id')
159 .custom(isIdValid), 112 .custom(isIdValid),
@@ -365,45 +318,6 @@ const usersVideosValidator = [
365 } 318 }
366] 319]
367 320
368const ensureUserRegistrationAllowed = [
369 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
370 const allowedParams = {
371 body: req.body,
372 ip: req.ip
373 }
374
375 const allowedResult = await Hooks.wrapPromiseFun(
376 isSignupAllowed,
377 allowedParams,
378 'filter:api.user.signup.allowed.result'
379 )
380
381 if (allowedResult.allowed === false) {
382 return res.fail({
383 status: HttpStatusCode.FORBIDDEN_403,
384 message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
385 })
386 }
387
388 return next()
389 }
390]
391
392const ensureUserRegistrationAllowedForIP = [
393 (req: express.Request, res: express.Response, next: express.NextFunction) => {
394 const allowed = isSignupAllowedForCurrentIP(req.ip)
395
396 if (allowed === false) {
397 return res.fail({
398 status: HttpStatusCode.FORBIDDEN_403,
399 message: 'You are not on a network authorized for registration.'
400 })
401 }
402
403 return next()
404 }
405]
406
407const usersAskResetPasswordValidator = [ 321const usersAskResetPasswordValidator = [
408 body('email') 322 body('email')
409 .isEmail(), 323 .isEmail(),
@@ -455,58 +369,6 @@ const usersResetPasswordValidator = [
455 } 369 }
456] 370]
457 371
458const usersAskSendVerifyEmailValidator = [
459 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
460
461 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
462 if (areValidationErrors(req, res)) return
463
464 const exists = await checkUserEmailExist(req.body.email, res, false)
465 if (!exists) {
466 logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
467 // Do not leak our emails
468 return res.status(HttpStatusCode.NO_CONTENT_204).end()
469 }
470
471 if (res.locals.user.pluginAuth) {
472 return res.fail({
473 status: HttpStatusCode.CONFLICT_409,
474 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
475 })
476 }
477
478 return next()
479 }
480]
481
482const usersVerifyEmailValidator = [
483 param('id')
484 .isInt().not().isEmpty().withMessage('Should have a valid id'),
485
486 body('verificationString')
487 .not().isEmpty().withMessage('Should have a valid verification string'),
488 body('isPendingEmail')
489 .optional()
490 .customSanitizer(toBooleanOrNull),
491
492 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
493 if (areValidationErrors(req, res)) return
494 if (!await checkUserIdExist(req.params.id, res)) return
495
496 const user = res.locals.user
497 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
498
499 if (redisVerificationString !== req.body.verificationString) {
500 return res.fail({
501 status: HttpStatusCode.FORBIDDEN_403,
502 message: 'Invalid verification string.'
503 })
504 }
505
506 return next()
507 }
508]
509
510const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => { 372const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
511 return [ 373 return [
512 body('currentPassword').optional().custom(exists), 374 body('currentPassword').optional().custom(exists),
@@ -603,21 +465,16 @@ export {
603 usersListValidator, 465 usersListValidator,
604 usersAddValidator, 466 usersAddValidator,
605 deleteMeValidator, 467 deleteMeValidator,
606 usersRegisterValidator,
607 usersBlockingValidator, 468 usersBlockingValidator,
608 usersRemoveValidator, 469 usersRemoveValidator,
609 usersUpdateValidator, 470 usersUpdateValidator,
610 usersUpdateMeValidator, 471 usersUpdateMeValidator,
611 usersVideoRatingValidator, 472 usersVideoRatingValidator,
612 usersCheckCurrentPasswordFactory, 473 usersCheckCurrentPasswordFactory,
613 ensureUserRegistrationAllowed,
614 ensureUserRegistrationAllowedForIP,
615 usersGetValidator, 474 usersGetValidator,
616 usersVideosValidator, 475 usersVideosValidator,
617 usersAskResetPasswordValidator, 476 usersAskResetPasswordValidator,
618 usersResetPasswordValidator, 477 usersResetPasswordValidator,
619 usersAskSendVerifyEmailValidator,
620 usersVerifyEmailValidator,
621 userAutocompleteValidator, 478 userAutocompleteValidator,
622 ensureAuthUserOwnsAccountValidator, 479 ensureAuthUserOwnsAccountValidator,
623 ensureCanModerateUser, 480 ensureCanModerateUser,
diff --git a/server/models/user/sql/user-notitication-list-query-builder.ts b/server/models/user/sql/user-notitication-list-query-builder.ts
index d11546df0..7b29807a3 100644
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -180,7 +180,9 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
180 "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type", 180 "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type",
181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename", 181 "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id", 182 "Account->Actor->Server"."id" AS "Account.Actor.Server.id",
183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host"` 183 "Account->Actor->Server"."host" AS "Account.Actor.Server.host",
184 "UserRegistration"."id" AS "UserRegistration.id",
185 "UserRegistration"."username" AS "UserRegistration.username"`
184 } 186 }
185 187
186 private getJoins () { 188 private getJoins () {
@@ -196,74 +198,76 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
196 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id" 198 ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
197 ) ON "UserNotificationModel"."videoId" = "Video"."id" 199 ) ON "UserNotificationModel"."videoId" = "Video"."id"
198 200
199 LEFT JOIN ( 201 LEFT JOIN (
200 "videoComment" AS "VideoComment" 202 "videoComment" AS "VideoComment"
201 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id" 203 INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
202 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id" 204 INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
203 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars" 205 LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
204 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId" 206 ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
205 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} 207 AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
206 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server" 208 LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
207 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id" 209 ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
208 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id" 210 INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
209 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id" 211 ) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
212
213 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
214 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
215 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
216 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
217 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
218 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
219 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
220 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
221 LEFT JOIN (
222 "account" AS "Abuse->FlaggedAccount"
223 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
224 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
225 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
226 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
227 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
228 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
229 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
210 230
211 LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id" 231 LEFT JOIN (
212 LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId" 232 "videoBlacklist" AS "VideoBlacklist"
213 LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id" 233 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
214 LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId" 234 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
215 LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
216 ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
217 LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
218 ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
219 LEFT JOIN (
220 "account" AS "Abuse->FlaggedAccount"
221 INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
222 LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
223 ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
224 AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
225 LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
226 ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
227 ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
228 235
229 LEFT JOIN ( 236 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
230 "videoBlacklist" AS "VideoBlacklist" 237 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
231 INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
232 ) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
233 238
234 LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id" 239 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
235 LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
236 240
237 LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id" 241 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
238 242
239 LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id" 243 LEFT JOIN (
244 "actorFollow" AS "ActorFollow"
245 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
246 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
248 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
249 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
250 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
251 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
252 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
253 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
254 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
256 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
257 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
258 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
259 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
260 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
240 261
241 LEFT JOIN ( 262 LEFT JOIN (
242 "actorFollow" AS "ActorFollow" 263 "account" AS "Account"
243 INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id" 264 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
244 INNER JOIN "account" AS "ActorFollow->ActorFollower->Account" 265 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
245 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId" 266 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
246 LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars" 267 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
247 ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId" 268 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
248 AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR} 269 ) ON "UserNotificationModel"."accountId" = "Account"."id"
249 LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
250 ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
251 INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
252 LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
253 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
254 LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
255 ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
256 LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
257 ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
258 ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
259 270
260 LEFT JOIN ( 271 LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"`
261 "account" AS "Account"
262 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
263 LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
264 ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
265 AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
266 LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
267 ) ON "UserNotificationModel"."accountId" = "Account"."id"`
268 } 272 }
269} 273}
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index 6e134158f..667ee7f5f 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -20,6 +20,7 @@ import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import' 20import { VideoImportModel } from '../video/video-import'
21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' 21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
22import { UserModel } from './user' 22import { UserModel } from './user'
23import { UserRegistrationModel } from './user-registration'
23 24
24@Table({ 25@Table({
25 tableName: 'userNotification', 26 tableName: 'userNotification',
@@ -98,6 +99,14 @@ import { UserModel } from './user'
98 [Op.ne]: null 99 [Op.ne]: null
99 } 100 }
100 } 101 }
102 },
103 {
104 fields: [ 'userRegistrationId' ],
105 where: {
106 userRegistrationId: {
107 [Op.ne]: null
108 }
109 }
101 } 110 }
102 ] as (ModelIndexesOptions & { where?: WhereOptions })[] 111 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
103}) 112})
@@ -241,6 +250,18 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
241 }) 250 })
242 Application: ApplicationModel 251 Application: ApplicationModel
243 252
253 @ForeignKey(() => UserRegistrationModel)
254 @Column
255 userRegistrationId: number
256
257 @BelongsTo(() => UserRegistrationModel, {
258 foreignKey: {
259 allowNull: true
260 },
261 onDelete: 'cascade'
262 })
263 UserRegistration: UserRegistrationModel
264
244 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 265 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
245 const where = { userId } 266 const where = { userId }
246 267
@@ -416,6 +437,10 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
416 ? { latestVersion: this.Application.latestPeerTubeVersion } 437 ? { latestVersion: this.Application.latestPeerTubeVersion }
417 : undefined 438 : undefined
418 439
440 const registration = this.UserRegistration
441 ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
442 : undefined
443
419 return { 444 return {
420 id: this.id, 445 id: this.id,
421 type: this.type, 446 type: this.type,
@@ -429,6 +454,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
429 actorFollow, 454 actorFollow,
430 plugin, 455 plugin,
431 peertube, 456 peertube,
457 registration,
432 createdAt: this.createdAt.toISOString(), 458 createdAt: this.createdAt.toISOString(),
433 updatedAt: this.updatedAt.toISOString() 459 updatedAt: this.updatedAt.toISOString()
434 } 460 }
diff --git a/server/models/user/user-registration.ts b/server/models/user/user-registration.ts
new file mode 100644
index 000000000..adda3cc7e
--- /dev/null
+++ b/server/models/user/user-registration.ts
@@ -0,0 +1,259 @@
1import { FindOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeCreate,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 IsEmail,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import {
17 isRegistrationModerationResponseValid,
18 isRegistrationReasonValid,
19 isRegistrationStateValid
20} from '@server/helpers/custom-validators/user-registration'
21import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
22import { cryptPassword } from '@server/helpers/peertube-crypto'
23import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
24import { MRegistration, MRegistrationFormattable } from '@server/types/models'
25import { UserRegistration, UserRegistrationState } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
28import { getSort, throwIfNotValid } from '../shared'
29import { UserModel } from './user'
30
31@Table({
32 tableName: 'userRegistration',
33 indexes: [
34 {
35 fields: [ 'username' ],
36 unique: true
37 },
38 {
39 fields: [ 'email' ],
40 unique: true
41 },
42 {
43 fields: [ 'channelHandle' ],
44 unique: true
45 },
46 {
47 fields: [ 'userId' ],
48 unique: true
49 }
50 ]
51})
52export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
53
54 @AllowNull(false)
55 @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
56 @Column
57 state: UserRegistrationState
58
59 @AllowNull(false)
60 @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
61 @Column(DataType.TEXT)
62 registrationReason: string
63
64 @AllowNull(true)
65 @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
66 @Column(DataType.TEXT)
67 moderationResponse: string
68
69 @AllowNull(true)
70 @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
71 @Column
72 password: string
73
74 @AllowNull(false)
75 @Column
76 username: string
77
78 @AllowNull(false)
79 @IsEmail
80 @Column(DataType.STRING(400))
81 email: string
82
83 @AllowNull(true)
84 @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
85 @Column
86 emailVerified: boolean
87
88 @AllowNull(true)
89 @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
90 @Column
91 accountDisplayName: string
92
93 @AllowNull(true)
94 @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
95 @Column
96 channelHandle: string
97
98 @AllowNull(true)
99 @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
100 @Column
101 channelDisplayName: string
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: true
116 },
117 onDelete: 'SET NULL'
118 })
119 User: UserModel
120
121 @BeforeCreate
122 static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
123 instance.password = await cryptPassword(instance.password)
124 }
125
126 static load (id: number): Promise<MRegistration> {
127 return UserRegistrationModel.findByPk(id)
128 }
129
130 static loadByEmail (email: string): Promise<MRegistration> {
131 const query = {
132 where: { email }
133 }
134
135 return UserRegistrationModel.findOne(query)
136 }
137
138 static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
139 const query = {
140 where: {
141 [Op.or]: [
142 { email: emailOrUsername },
143 { username: emailOrUsername }
144 ]
145 }
146 }
147
148 return UserRegistrationModel.findOne(query)
149 }
150
151 static loadByEmailOrHandle (options: {
152 email: string
153 username: string
154 channelHandle?: string
155 }): Promise<MRegistration> {
156 const { email, username, channelHandle } = options
157
158 let or: WhereOptions = [
159 { email },
160 { channelHandle: username },
161 { username }
162 ]
163
164 if (channelHandle) {
165 or = or.concat([
166 { username: channelHandle },
167 { channelHandle }
168 ])
169 }
170
171 const query = {
172 where: {
173 [Op.or]: or
174 }
175 }
176
177 return UserRegistrationModel.findOne(query)
178 }
179
180 // ---------------------------------------------------------------------------
181
182 static listForApi (options: {
183 start: number
184 count: number
185 sort: string
186 search?: string
187 }) {
188 const { start, count, sort, search } = options
189
190 const where: WhereOptions = {}
191
192 if (search) {
193 Object.assign(where, {
194 [Op.or]: [
195 {
196 email: {
197 [Op.iLike]: '%' + search + '%'
198 }
199 },
200 {
201 username: {
202 [Op.iLike]: '%' + search + '%'
203 }
204 }
205 ]
206 })
207 }
208
209 const query: FindOptions = {
210 offset: start,
211 limit: count,
212 order: getSort(sort),
213 where,
214 include: [
215 {
216 model: UserModel.unscoped(),
217 required: false
218 }
219 ]
220 }
221
222 return Promise.all([
223 UserRegistrationModel.count(query),
224 UserRegistrationModel.findAll<MRegistrationFormattable>(query)
225 ]).then(([ total, data ]) => ({ total, data }))
226 }
227
228 // ---------------------------------------------------------------------------
229
230 toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
231 return {
232 id: this.id,
233
234 state: {
235 id: this.state,
236 label: USER_REGISTRATION_STATES[this.state]
237 },
238
239 registrationReason: this.registrationReason,
240 moderationResponse: this.moderationResponse,
241
242 username: this.username,
243 email: this.email,
244 emailVerified: this.emailVerified,
245
246 accountDisplayName: this.accountDisplayName,
247
248 channelHandle: this.channelHandle,
249 channelDisplayName: this.channelDisplayName,
250
251 createdAt: this.createdAt,
252 updatedAt: this.updatedAt,
253
254 user: this.User
255 ? { id: this.User.id }
256 : null
257 }
258 }
259}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 0932a367a..c5c8a1b30 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -441,16 +441,17 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
441 }) 441 })
442 OAuthTokens: OAuthTokenModel[] 442 OAuthTokens: OAuthTokenModel[]
443 443
444 // Used if we already set an encrypted password in user model
445 skipPasswordEncryption = false
446
444 @BeforeCreate 447 @BeforeCreate
445 @BeforeUpdate 448 @BeforeUpdate
446 static cryptPasswordIfNeeded (instance: UserModel) { 449 static async cryptPasswordIfNeeded (instance: UserModel) {
447 if (instance.changed('password') && instance.password) { 450 if (instance.skipPasswordEncryption) return
448 return cryptPassword(instance.password) 451 if (!instance.changed('password')) return
449 .then(hash => { 452 if (!instance.password) return
450 instance.password = hash 453
451 return undefined 454 instance.password = await cryptPassword(instance.password)
452 })
453 }
454 } 455 }
455 456
456 @AfterUpdate 457 @AfterUpdate
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 6fea4dac2..c1c379b98 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -8,6 +8,7 @@ import {
8 MActorUrl, 8 MActorUrl,
9 MChannelBannerAccountDefault, 9 MChannelBannerAccountDefault,
10 MChannelSyncChannel, 10 MChannelSyncChannel,
11 MRegistration,
11 MStreamingPlaylist, 12 MStreamingPlaylist,
12 MUserAccountUrl, 13 MUserAccountUrl,
13 MVideoChangeOwnershipFull, 14 MVideoChangeOwnershipFull,
@@ -171,6 +172,7 @@ declare module 'express' {
171 actorFull?: MActorFull 172 actorFull?: MActorFull
172 173
173 user?: MUserDefault 174 user?: MUserDefault
175 userRegistration?: MRegistration
174 176
175 server?: MServer 177 server?: MServer
176 178
diff --git a/server/types/models/user/index.ts b/server/types/models/user/index.ts
index 6657b2128..5738f4107 100644
--- a/server/types/models/user/index.ts
+++ b/server/types/models/user/index.ts
@@ -1,4 +1,5 @@
1export * from './user' 1export * from './user'
2export * from './user-notification' 2export * from './user-notification'
3export * from './user-notification-setting' 3export * from './user-notification-setting'
4export * from './user-registration'
4export * from './user-video-history' 5export * from './user-video-history'
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index d4715a0b6..a732c8aa9 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse
3import { ApplicationModel } from '@server/models/application/application' 3import { ApplicationModel } from '@server/models/application/application'
4import { PluginModel } from '@server/models/server/plugin' 4import { PluginModel } from '@server/models/server/plugin'
5import { UserNotificationModel } from '@server/models/user/user-notification' 5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { UserRegistrationModel } from '@server/models/user/user-registration'
6import { PickWith, PickWithOpt } from '@shared/typescript-utils' 7import { PickWith, PickWithOpt } from '@shared/typescript-utils'
7import { AbuseModel } from '../../../models/abuse/abuse' 8import { AbuseModel } from '../../../models/abuse/abuse'
8import { AccountModel } from '../../../models/account/account' 9import { AccountModel } from '../../../models/account/account'
@@ -94,13 +95,16 @@ export module UserNotificationIncludes {
94 95
95 export type ApplicationInclude = 96 export type ApplicationInclude =
96 Pick<ApplicationModel, 'latestPeerTubeVersion'> 97 Pick<ApplicationModel, 'latestPeerTubeVersion'>
98
99 export type UserRegistrationInclude =
100 Pick<UserRegistrationModel, 'id' | 'username'>
97} 101}
98 102
99// ############################################################################ 103// ############################################################################
100 104
101export type MUserNotification = 105export type MUserNotification =
102 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' | 106 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
103 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'> 107 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
104 108
105// ############################################################################ 109// ############################################################################
106 110
@@ -114,4 +118,5 @@ export type UserNotificationModelForApi =
114 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & 118 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
115 Use<'Plugin', UserNotificationIncludes.PluginInclude> & 119 Use<'Plugin', UserNotificationIncludes.PluginInclude> &
116 Use<'Application', UserNotificationIncludes.ApplicationInclude> & 120 Use<'Application', UserNotificationIncludes.ApplicationInclude> &
117 Use<'Account', UserNotificationIncludes.AccountIncludeActor> 121 Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
122 Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude>
diff --git a/server/types/models/user/user-registration.ts b/server/types/models/user/user-registration.ts
new file mode 100644
index 000000000..216423cc9
--- /dev/null
+++ b/server/types/models/user/user-registration.ts
@@ -0,0 +1,15 @@
1import { UserRegistrationModel } from '@server/models/user/user-registration'
2import { PickWith } from '@shared/typescript-utils'
3import { MUserId } from './user'
4
5type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
6
7// ############################################################################
8
9export type MRegistration = Omit<UserRegistrationModel, 'User'>
10
11// ############################################################################
12
13export type MRegistrationFormattable =
14 MRegistration &
15 Use<'User', MUserId>