diff options
author | Chocobozzz <me@florianbigard.com> | 2023-01-19 09:27:16 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2023-01-19 13:53:40 +0100 |
commit | e364e31e25bd1d4b8d801c845a96d6be708f0a18 (patch) | |
tree | 220785a42af361706eb8243960c5da9cddf4d2be /server | |
parent | bc48e33b80f357767b98c1d310b04bdda24c6d46 (diff) | |
download | PeerTube-e364e31e25bd1d4b8d801c845a96d6be708f0a18.tar.gz PeerTube-e364e31e25bd1d4b8d801c845a96d6be708f0a18.tar.zst PeerTube-e364e31e25bd1d4b8d801c845a96d6be708f0a18.zip |
Implement signup approval in server
Diffstat (limited to 'server')
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 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode } from '@shared/models' | ||
3 | import { CONFIG } from '../../../initializers/config' | ||
4 | import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' | ||
5 | import { asyncMiddleware, buildRateLimiter } from '../../../middlewares' | ||
6 | import { | ||
7 | registrationVerifyEmailValidator, | ||
8 | usersAskSendVerifyEmailValidator, | ||
9 | usersVerifyEmailValidator | ||
10 | } from '../../../middlewares/validators' | ||
11 | |||
12 | const askSendEmailLimiter = buildRateLimiter({ | ||
13 | windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, | ||
14 | max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX | ||
15 | }) | ||
16 | |||
17 | const emailVerificationRouter = express.Router() | ||
18 | |||
19 | emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ], | ||
20 | askSendEmailLimiter, | ||
21 | asyncMiddleware(usersAskSendVerifyEmailValidator), | ||
22 | asyncMiddleware(reSendVerifyUserEmail) | ||
23 | ) | ||
24 | |||
25 | emailVerificationRouter.post('/:id/verify-email', | ||
26 | asyncMiddleware(usersVerifyEmailValidator), | ||
27 | asyncMiddleware(verifyUserEmail) | ||
28 | ) | ||
29 | |||
30 | emailVerificationRouter.post('/registrations/:registrationId/verify-email', | ||
31 | asyncMiddleware(registrationVerifyEmailValidator), | ||
32 | asyncMiddleware(verifyRegistrationEmail) | ||
33 | ) | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | emailVerificationRouter | ||
39 | } | ||
40 | |||
41 | async 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 | |||
51 | async 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 | |||
65 | async 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' | |||
4 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' | 4 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' |
5 | import { MUserAccountDefault } from '@server/types/models' | 5 | import { MUserAccountDefault } from '@server/types/models' |
6 | import { pick } from '@shared/core-utils' | 6 | import { pick } from '@shared/core-utils' |
7 | import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' | 7 | import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models' |
8 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' | 8 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' |
9 | import { logger } from '../../../helpers/logger' | 9 | import { logger } from '../../../helpers/logger' |
10 | import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' | 10 | import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' |
11 | import { CONFIG } from '../../../initializers/config' | ||
12 | import { WEBSERVER } from '../../../initializers/constants' | 11 | import { WEBSERVER } from '../../../initializers/constants' |
13 | import { sequelizeTypescript } from '../../../initializers/database' | 12 | import { sequelizeTypescript } from '../../../initializers/database' |
14 | import { Emailer } from '../../../lib/emailer' | 13 | import { Emailer } from '../../../lib/emailer' |
15 | import { Notifier } from '../../../lib/notifier' | ||
16 | import { Redis } from '../../../lib/redis' | 14 | import { Redis } from '../../../lib/redis' |
17 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' | 15 | import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user' |
18 | import { | 16 | import { |
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' |
38 | import { | 32 | import { |
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' |
46 | import { UserModel } from '../../../models/user/user' | 38 | import { UserModel } from '../../../models/user/user' |
39 | import { emailVerificationRouter } from './email-verification' | ||
47 | import { meRouter } from './me' | 40 | import { meRouter } from './me' |
48 | import { myAbusesRouter } from './my-abuses' | 41 | import { myAbusesRouter } from './my-abuses' |
49 | import { myBlocklistRouter } from './my-blocklist' | 42 | import { myBlocklistRouter } from './my-blocklist' |
@@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history' | |||
51 | import { myNotificationsRouter } from './my-notifications' | 44 | import { myNotificationsRouter } from './my-notifications' |
52 | import { mySubscriptionsRouter } from './my-subscriptions' | 45 | import { mySubscriptionsRouter } from './my-subscriptions' |
53 | import { myVideoPlaylistsRouter } from './my-video-playlists' | 46 | import { myVideoPlaylistsRouter } from './my-video-playlists' |
47 | import { registrationsRouter } from './registrations' | ||
54 | import { twoFactorRouter } from './two-factor' | 48 | import { twoFactorRouter } from './two-factor' |
55 | 49 | ||
56 | const auditLogger = auditLoggerFactory('users') | 50 | const auditLogger = auditLoggerFactory('users') |
57 | 51 | ||
58 | const signupRateLimiter = buildRateLimiter({ | ||
59 | windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, | ||
60 | max: CONFIG.RATES_LIMIT.SIGNUP.MAX, | ||
61 | skipFailedRequests: true | ||
62 | }) | ||
63 | |||
64 | const askSendEmailLimiter = buildRateLimiter({ | ||
65 | windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, | ||
66 | max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX | ||
67 | }) | ||
68 | |||
69 | const usersRouter = express.Router() | 52 | const usersRouter = express.Router() |
53 | usersRouter.use('/', emailVerificationRouter) | ||
54 | usersRouter.use('/', registrationsRouter) | ||
70 | usersRouter.use('/', twoFactorRouter) | 55 | usersRouter.use('/', twoFactorRouter) |
71 | usersRouter.use('/', tokensRouter) | 56 | usersRouter.use('/', tokensRouter) |
72 | usersRouter.use('/', myNotificationsRouter) | 57 | usersRouter.use('/', myNotificationsRouter) |
@@ -122,14 +107,6 @@ usersRouter.post('/', | |||
122 | asyncRetryTransactionMiddleware(createUser) | 107 | asyncRetryTransactionMiddleware(createUser) |
123 | ) | 108 | ) |
124 | 109 | ||
125 | usersRouter.post('/register', | ||
126 | signupRateLimiter, | ||
127 | asyncMiddleware(ensureUserRegistrationAllowed), | ||
128 | ensureUserRegistrationAllowedForIP, | ||
129 | asyncMiddleware(usersRegisterValidator), | ||
130 | asyncRetryTransactionMiddleware(registerUser) | ||
131 | ) | ||
132 | |||
133 | usersRouter.put('/:id', | 110 | usersRouter.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 | ||
159 | usersRouter.post('/ask-send-verify-email', | ||
160 | askSendEmailLimiter, | ||
161 | asyncMiddleware(usersAskSendVerifyEmailValidator), | ||
162 | asyncMiddleware(reSendVerifyUserEmail) | ||
163 | ) | ||
164 | |||
165 | usersRouter.post('/:id/verify-email', | ||
166 | asyncMiddleware(usersVerifyEmailValidator), | ||
167 | asyncMiddleware(verifyUserEmail) | ||
168 | ) | ||
169 | |||
170 | // --------------------------------------------------------------------------- | 136 | // --------------------------------------------------------------------------- |
171 | 137 | ||
172 | export { | 138 | export { |
@@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) { | |||
218 | }) | 184 | }) |
219 | } | 185 | } |
220 | 186 | ||
221 | async 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 | |||
250 | async function unblockUser (req: express.Request, res: express.Response) { | 187 | async 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 | ||
363 | async 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 | |||
371 | async 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 | |||
385 | async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) { | 300 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { Emailer } from '@server/lib/emailer' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState, UserRight } from '@shared/models' | ||
7 | import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' | ||
8 | import { logger } from '../../../helpers/logger' | ||
9 | import { CONFIG } from '../../../initializers/config' | ||
10 | import { Notifier } from '../../../lib/notifier' | ||
11 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' | ||
12 | import { | ||
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 | |||
31 | const auditLogger = auditLoggerFactory('users') | ||
32 | |||
33 | const registrationRateLimiter = buildRateLimiter({ | ||
34 | windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, | ||
35 | max: CONFIG.RATES_LIMIT.SIGNUP.MAX, | ||
36 | skipFailedRequests: true | ||
37 | }) | ||
38 | |||
39 | const registrationsRouter = express.Router() | ||
40 | |||
41 | registrationsRouter.post('/registrations/request', | ||
42 | registrationRateLimiter, | ||
43 | asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')), | ||
44 | ensureUserRegistrationAllowedForIP, | ||
45 | asyncMiddleware(usersRequestRegistrationValidator), | ||
46 | asyncRetryTransactionMiddleware(requestRegistration) | ||
47 | ) | ||
48 | |||
49 | registrationsRouter.post('/registrations/:registrationId/accept', | ||
50 | authenticate, | ||
51 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
52 | asyncMiddleware(acceptOrRejectRegistrationValidator), | ||
53 | asyncRetryTransactionMiddleware(acceptRegistration) | ||
54 | ) | ||
55 | registrationsRouter.post('/registrations/:registrationId/reject', | ||
56 | authenticate, | ||
57 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
58 | asyncMiddleware(acceptOrRejectRegistrationValidator), | ||
59 | asyncRetryTransactionMiddleware(rejectRegistration) | ||
60 | ) | ||
61 | |||
62 | registrationsRouter.delete('/registrations/:registrationId', | ||
63 | authenticate, | ||
64 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
65 | asyncMiddleware(getRegistrationValidator), | ||
66 | asyncRetryTransactionMiddleware(deleteRegistration) | ||
67 | ) | ||
68 | |||
69 | registrationsRouter.get('/registrations', | ||
70 | authenticate, | ||
71 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
72 | paginationValidator, | ||
73 | userRegistrationsSortValidator, | ||
74 | setDefaultSort, | ||
75 | setDefaultPagination, | ||
76 | listRegistrationsValidator, | ||
77 | asyncMiddleware(listRegistrations) | ||
78 | ) | ||
79 | |||
80 | registrationsRouter.post('/register', | ||
81 | registrationRateLimiter, | ||
82 | asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')), | ||
83 | ensureUserRegistrationAllowedForIP, | ||
84 | asyncMiddleware(usersDirectRegistrationValidator), | ||
85 | asyncRetryTransactionMiddleware(registerUser) | ||
86 | ) | ||
87 | |||
88 | // --------------------------------------------------------------------------- | ||
89 | |||
90 | export { | ||
91 | registrationsRouter | ||
92 | } | ||
93 | |||
94 | // --------------------------------------------------------------------------- | ||
95 | |||
96 | async 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 | |||
126 | async 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 | |||
164 | async 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 | |||
181 | async 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 | |||
193 | async 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 | |||
209 | async 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 @@ | |||
1 | import validator from 'validator' | ||
2 | import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants' | ||
3 | import { exists } from './misc' | ||
4 | |||
5 | const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS | ||
6 | |||
7 | function isRegistrationStateValid (value: string) { | ||
8 | return exists(value) && USER_REGISTRATION_STATES[value] !== undefined | ||
9 | } | ||
10 | |||
11 | function isRegistrationModerationResponseValid (value: string) { | ||
12 | return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE) | ||
13 | } | ||
14 | |||
15 | function isRegistrationReasonValid (value: string) { | ||
16 | return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE) | ||
17 | } | ||
18 | |||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
21 | export { | ||
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' | |||
6 | import { | 6 | import { |
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 | ||
28 | const LAST_MIGRATION_VERSION = 745 | 29 | const 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 | ||
526 | const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = { | ||
527 | [UserRegistrationState.PENDING]: 'Pending', | ||
528 | [UserRegistrationState.REJECTED]: 'Rejected', | ||
529 | [UserRegistrationState.ACCEPTED]: 'Accepted' | ||
530 | } | ||
531 | |||
519 | const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { | 532 | const 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 | ||
661 | const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes | 674 | const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes |
662 | 675 | ||
663 | const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes | 676 | const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes |
664 | 677 | ||
665 | const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { | 678 | const 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' | |||
5 | import { VideoTrackerModel } from '@server/models/server/video-tracker' | 5 | import { VideoTrackerModel } from '@server/models/server/video-tracker' |
6 | import { UserModel } from '@server/models/user/user' | 6 | import { UserModel } from '@server/models/user/user' |
7 | import { UserNotificationModel } from '@server/models/user/user-notification' | 7 | import { UserNotificationModel } from '@server/models/user/user-notification' |
8 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
8 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' | 9 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' |
10 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' | ||
9 | import { VideoJobInfoModel } from '@server/models/video/video-job-info' | 11 | import { VideoJobInfoModel } from '@server/models/video/video-job-info' |
10 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | 12 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' |
11 | import { VideoSourceModel } from '@server/models/video/video-source' | 13 | import { VideoSourceModel } from '@server/models/video/video-source' |
@@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla | |||
50 | import { VideoTagModel } from '../models/video/video-tag' | 52 | import { VideoTagModel } from '../models/video/video-tag' |
51 | import { VideoViewModel } from '../models/view/video-view' | 53 | import { VideoViewModel } from '../models/view/video-view' |
52 | import { CONFIG } from './config' | 54 | import { CONFIG } from './config' |
53 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' | ||
54 | 55 | ||
55 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 56 | require('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 | |||
2 | import * as Sequelize from 'sequelize' | ||
3 | |||
4 | async 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 | |||
48 | async function down (utils: { | ||
49 | queryInterface: Sequelize.QueryInterface | ||
50 | transaction: Sequelize.Transaction | ||
51 | }) { | ||
52 | await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction }) | ||
53 | } | ||
54 | |||
55 | export { | ||
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, { | |||
11 | import { randomBytesPromise } from '@server/helpers/core-utils' | 11 | import { randomBytesPromise } from '@server/helpers/core-utils' |
12 | import { isOTPValid } from '@server/helpers/otp' | 12 | import { isOTPValid } from '@server/helpers/otp' |
13 | import { CONFIG } from '@server/initializers/config' | 13 | import { CONFIG } from '@server/initializers/config' |
14 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
14 | import { MOAuthClient } from '@server/types/models' | 15 | import { MOAuthClient } from '@server/types/models' |
15 | import { sha1 } from '@shared/extra-utils' | 16 | import { sha1 } from '@shared/extra-utils' |
16 | import { HttpStatusCode } from '@shared/models' | 17 | import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models' |
17 | import { OTP } from '../../initializers/constants' | 18 | import { OTP } from '../../initializers/constants' |
18 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' | 19 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' |
19 | 20 | ||
20 | class MissingTwoFactorError extends Error { | 21 | class 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 | ||
25 | class InvalidTwoFactorError extends Error { | 26 | class 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 | |||
31 | class RegistrationWaitingForApproval extends Error { | ||
32 | code = HttpStatusCode.BAD_REQUEST_400 | ||
33 | name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL | ||
34 | } | ||
35 | |||
36 | class 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' | |||
3 | import { createTransport, Transporter } from 'nodemailer' | 3 | import { createTransport, Transporter } from 'nodemailer' |
4 | import { join } from 'path' | 4 | import { join } from 'path' |
5 | import { arrayify, root } from '@shared/core-utils' | 5 | import { arrayify, root } from '@shared/core-utils' |
6 | import { EmailPayload } from '@shared/models' | 6 | import { EmailPayload, UserRegistrationState } from '@shared/models' |
7 | import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' | 7 | import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' |
8 | import { isTestOrDevInstance } from '../helpers/core-utils' | 8 | import { isTestOrDevInstance } from '../helpers/core-utils' |
9 | import { bunyanLogger, logger } from '../helpers/logger' | 9 | import { bunyanLogger, logger } from '../helpers/logger' |
10 | import { CONFIG, isEmailEnabled } from '../initializers/config' | 10 | import { CONFIG, isEmailEnabled } from '../initializers/config' |
11 | import { WEBSERVER } from '../initializers/constants' | 11 | import { WEBSERVER } from '../initializers/constants' |
12 | import { MUser } from '../types/models' | 12 | import { MRegistration, MUser } from '../types/models' |
13 | import { JobQueue } from './job-queue' | 13 | import { JobQueue } from './job-queue' |
14 | 14 | ||
15 | const Email = require('email-templates') | 15 | const 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 @@ | |||
1 | extends ../common/greetings | ||
2 | |||
3 | block title | ||
4 | | Congratulation #{username}, your registration request has been accepted! | ||
5 | |||
6 | block 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 @@ | |||
1 | extends ../common/greetings | ||
2 | |||
3 | block title | ||
4 | | Registration request of your account #{username} has rejected | ||
5 | |||
6 | block 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 @@ | |||
1 | extends ../common/greetings | ||
2 | |||
3 | block title | ||
4 | | A new user wants to register | ||
5 | |||
6 | block 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 @@ | |||
1 | extends ../common/greetings | 1 | extends ../common/greetings |
2 | 2 | ||
3 | block title | 3 | block title |
4 | | Account verification | 4 | | Email verification |
5 | 5 | ||
6 | block content | 6 | block 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 @@ | |||
1 | import { MUser, MUserDefault } from '@server/types/models/user' | 1 | import { MRegistration, MUser, MUserDefault } from '@server/types/models/user' |
2 | import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' | 2 | import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' |
3 | import { UserNotificationSettingValue } from '../../../shared/models/users' | 3 | import { UserNotificationSettingValue } from '../../../shared/models/users' |
4 | import { logger } from '../../helpers/logger' | 4 | import { 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 | |||
6 | import { UserNotificationType, UserRight } from '@shared/models' | 6 | import { UserNotificationType, UserRight } from '@shared/models' |
7 | import { AbstractNotification } from '../common/abstract-notification' | 7 | import { AbstractNotification } from '../common/abstract-notification' |
8 | 8 | ||
9 | export class RegistrationForModerators extends AbstractNotification <MUserDefault> { | 9 | export 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 @@ | |||
1 | export * from './new-peertube-version-for-admins' | 1 | export * from './new-peertube-version-for-admins' |
2 | export * from './new-plugin-version-for-admins' | 2 | export * from './new-plugin-version-for-admins' |
3 | export * from './registration-for-moderators' | 3 | export * from './direct-registration-for-moderators' |
4 | export * 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 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
2 | import { UserModel } from '@server/models/user/user' | ||
3 | import { UserNotificationModel } from '@server/models/user/user-notification' | ||
4 | import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models' | ||
5 | import { UserNotificationType, UserRight } from '@shared/models' | ||
6 | import { AbstractNotification } from '../common/abstract-notification' | ||
7 | |||
8 | export 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 | ||
5 | const isCidr = require('is-cidr') | 5 | const isCidr = require('is-cidr') |
6 | 6 | ||
7 | async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> { | 7 | export type SignupMode = 'direct-registration' | 'request-registration' |
8 | |||
9 | async 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' | |||
10 | import { AccountModel } from '../models/account/account' | 10 | import { AccountModel } from '../models/account/account' |
11 | import { UserNotificationSettingModel } from '../models/user/user-notification-setting' | 11 | import { UserNotificationSettingModel } from '../models/user/user-notification-setting' |
12 | import { MAccountDefault, MChannelActor } from '../types/models' | 12 | import { MAccountDefault, MChannelActor } from '../types/models' |
13 | import { MUser, MUserDefault, MUserId } from '../types/models/user' | 13 | import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user' |
14 | import { generateAndSaveActorKeys } from './activitypub/actors' | 14 | import { generateAndSaveActorKeys } from './activitypub/actors' |
15 | import { getLocalAccountActivityPubUrl } from './activitypub/url' | 15 | import { getLocalAccountActivityPubUrl } from './activitypub/url' |
16 | import { Emailer } from './emailer' | 16 | import { 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 | ||
162 | async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) { | 162 | async 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 | |||
177 | async 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 | ||
267 | async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) { | 283 | async 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' | |||
21 | export * from './sort' | 21 | export * from './sort' |
22 | export * from './static' | 22 | export * from './static' |
23 | export * from './themes' | 23 | export * from './themes' |
24 | export * from './user-email-verification' | ||
24 | export * from './user-history' | 25 | export * from './user-history' |
25 | export * from './user-notifications' | 26 | export * from './user-notifications' |
27 | export * from './user-registrations' | ||
26 | export * from './user-subscriptions' | 28 | export * from './user-subscriptions' |
27 | export * from './users' | 29 | export * from './users' |
28 | export * from './videos' | 30 | export * from './videos' |
diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts new file mode 100644 index 000000000..dbc7dda06 --- /dev/null +++ b/server/middlewares/validators/shared/user-registrations.ts | |||
@@ -0,0 +1,60 @@ | |||
1 | import express from 'express' | ||
2 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
3 | import { MRegistration } from '@server/types/models' | ||
4 | import { forceNumber, pick } from '@shared/core-utils' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | |||
7 | function checkRegistrationIdExist (idArg: number | string, res: express.Response) { | ||
8 | const id = forceNumber(idArg) | ||
9 | return checkRegistrationExist(() => UserRegistrationModel.load(id), res) | ||
10 | } | ||
11 | |||
12 | function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) { | ||
13 | return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse) | ||
14 | } | ||
15 | |||
16 | async 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 | |||
37 | async 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 | |||
55 | export { | ||
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 | ||
17 | async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { | 17 | async 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 | |||
58 | export { | 58 | export { |
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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { query } from 'express-validator' | 2 | import { query } from 'express-validator' |
3 | |||
4 | import { SORTABLE_COLUMNS } from '../../initializers/constants' | 3 | import { SORTABLE_COLUMNS } from '../../initializers/constants' |
5 | import { areValidationErrors } from './shared' | 4 | import { areValidationErrors } from './shared' |
6 | 5 | ||
6 | export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS) | ||
7 | export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) | ||
8 | export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) | ||
9 | export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) | ||
10 | export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS) | ||
11 | export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS) | ||
12 | export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH) | ||
13 | export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) | ||
14 | export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH) | ||
15 | export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS) | ||
16 | export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) | ||
17 | export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES) | ||
18 | export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS) | ||
19 | export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS) | ||
20 | export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS) | ||
21 | export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING) | ||
22 | export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) | ||
23 | export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) | ||
24 | export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) | ||
25 | export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS) | ||
26 | export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) | ||
27 | export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) | ||
28 | export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) | ||
29 | export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) | ||
30 | export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) | ||
31 | |||
32 | export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) | ||
33 | export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) | ||
34 | |||
35 | export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS) | ||
36 | |||
37 | // --------------------------------------------------------------------------- | ||
38 | |||
7 | function checkSortFactory (columns: string[], tags: string[] = []) { | 39 | function 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 | |||
31 | const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS) | ||
32 | const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS) | ||
33 | const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ]) | ||
34 | const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES) | ||
35 | const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS) | ||
36 | const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS) | ||
37 | const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH) | ||
38 | const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) | ||
39 | const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH) | ||
40 | const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS) | ||
41 | const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) | ||
42 | const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES) | ||
43 | const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS) | ||
44 | const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS) | ||
45 | const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS) | ||
46 | const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING) | ||
47 | const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) | ||
48 | const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) | ||
49 | const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) | ||
50 | const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS) | ||
51 | const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS) | ||
52 | const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS) | ||
53 | const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) | ||
54 | const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) | ||
55 | const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) | ||
56 | |||
57 | const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) | ||
58 | const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) | ||
59 | |||
60 | // --------------------------------------------------------------------------- | ||
61 | |||
62 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { toBooleanOrNull } from '@server/helpers/custom-validators/misc' | ||
4 | import { HttpStatusCode } from '@shared/models' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { Redis } from '../../lib/redis' | ||
7 | import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared' | ||
8 | import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations' | ||
9 | |||
10 | const 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 | |||
38 | const 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 | |||
65 | const 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 | |||
89 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param, query, ValidationChain } from 'express-validator' | ||
3 | import { exists, isIdValid } from '@server/helpers/custom-validators/misc' | ||
4 | import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models' | ||
8 | import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users' | ||
9 | import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' | ||
10 | import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup' | ||
11 | import { ActorModel } from '../../models/actor/actor' | ||
12 | import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared' | ||
13 | import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations' | ||
14 | |||
15 | const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory() | ||
16 | |||
17 | const 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 | |||
42 | function 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 | |||
70 | const 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 | |||
87 | const 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 | |||
111 | const 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 | |||
125 | const 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 | |||
139 | export { | ||
140 | usersDirectRegistrationValidator, | ||
141 | usersRequestRegistrationValidator, | ||
142 | |||
143 | ensureUserRegistrationAllowedFactory, | ||
144 | ensureUserRegistrationAllowedForIP, | ||
145 | |||
146 | getRegistrationValidator, | ||
147 | listRegistrationsValidator, | ||
148 | |||
149 | acceptOrRejectRegistrationValidator | ||
150 | } | ||
151 | |||
152 | // --------------------------------------------------------------------------- | ||
153 | |||
154 | function 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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { body, param, query } from 'express-validator' | 2 | import { body, param, query } from 'express-validator' |
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { forceNumber } from '@shared/core-utils' | 3 | import { forceNumber } from '@shared/core-utils' |
5 | import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' | 4 | import { HttpStatusCode, UserRight, UserRole } from '@shared/models' |
6 | import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' | 5 | import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' |
7 | import { isThemeNameValid } from '../../helpers/custom-validators/plugins' | 6 | import { isThemeNameValid } from '../../helpers/custom-validators/plugins' |
8 | import { | 7 | import { |
@@ -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' |
27 | import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' | 26 | import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' |
28 | import { logger } from '../../helpers/logger' | 27 | import { logger } from '../../helpers/logger' |
29 | import { isThemeRegistered } from '../../lib/plugins/theme-utils' | 28 | import { isThemeRegistered } from '../../lib/plugins/theme-utils' |
30 | import { Redis } from '../../lib/redis' | 29 | import { Redis } from '../../lib/redis' |
31 | import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' | ||
32 | import { ActorModel } from '../../models/actor/actor' | 30 | import { ActorModel } from '../../models/actor/actor' |
33 | import { | 31 | import { |
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 | ||
112 | const 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 | |||
157 | const usersRemoveValidator = [ | 110 | const 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 | ||
368 | const 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 | |||
392 | const 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 | |||
407 | const usersAskResetPasswordValidator = [ | 321 | const usersAskResetPasswordValidator = [ |
408 | body('email') | 322 | body('email') |
409 | .isEmail(), | 323 | .isEmail(), |
@@ -455,58 +369,6 @@ const usersResetPasswordValidator = [ | |||
455 | } | 369 | } |
456 | ] | 370 | ] |
457 | 371 | ||
458 | const 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 | |||
482 | const 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 | |||
510 | const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => { | 372 | const 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' | |||
20 | import { VideoImportModel } from '../video/video-import' | 20 | import { VideoImportModel } from '../video/video-import' |
21 | import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' | 21 | import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' |
22 | import { UserModel } from './user' | 22 | import { UserModel } from './user' |
23 | import { 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 @@ | |||
1 | import { FindOptions, Op, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { | ||
17 | isRegistrationModerationResponseValid, | ||
18 | isRegistrationReasonValid, | ||
19 | isRegistrationStateValid | ||
20 | } from '@server/helpers/custom-validators/user-registration' | ||
21 | import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels' | ||
22 | import { cryptPassword } from '@server/helpers/peertube-crypto' | ||
23 | import { USER_REGISTRATION_STATES } from '@server/initializers/constants' | ||
24 | import { MRegistration, MRegistrationFormattable } from '@server/types/models' | ||
25 | import { UserRegistration, UserRegistrationState } from '@shared/models' | ||
26 | import { AttributesOnly } from '@shared/typescript-utils' | ||
27 | import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users' | ||
28 | import { getSort, throwIfNotValid } from '../shared' | ||
29 | import { 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 | }) | ||
52 | export 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 @@ | |||
1 | export * from './user' | 1 | export * from './user' |
2 | export * from './user-notification' | 2 | export * from './user-notification' |
3 | export * from './user-notification-setting' | 3 | export * from './user-notification-setting' |
4 | export * from './user-registration' | ||
4 | export * from './user-video-history' | 5 | export * from './user-video-history' |
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts index d4715a0b6..a732c8aa9 100644 --- a/server/types/models/user/user-notification.ts +++ b/server/types/models/user/user-notification.ts | |||
@@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse | |||
3 | import { ApplicationModel } from '@server/models/application/application' | 3 | import { ApplicationModel } from '@server/models/application/application' |
4 | import { PluginModel } from '@server/models/server/plugin' | 4 | import { PluginModel } from '@server/models/server/plugin' |
5 | import { UserNotificationModel } from '@server/models/user/user-notification' | 5 | import { UserNotificationModel } from '@server/models/user/user-notification' |
6 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
6 | import { PickWith, PickWithOpt } from '@shared/typescript-utils' | 7 | import { PickWith, PickWithOpt } from '@shared/typescript-utils' |
7 | import { AbuseModel } from '../../../models/abuse/abuse' | 8 | import { AbuseModel } from '../../../models/abuse/abuse' |
8 | import { AccountModel } from '../../../models/account/account' | 9 | import { 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 | ||
101 | export type MUserNotification = | 105 | export 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 @@ | |||
1 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
2 | import { PickWith } from '@shared/typescript-utils' | ||
3 | import { MUserId } from './user' | ||
4 | |||
5 | type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M> | ||
6 | |||
7 | // ############################################################################ | ||
8 | |||
9 | export type MRegistration = Omit<UserRegistrationModel, 'User'> | ||
10 | |||
11 | // ############################################################################ | ||
12 | |||
13 | export type MRegistrationFormattable = | ||
14 | MRegistration & | ||
15 | Use<'User', MUserId> | ||