aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts2
-rw-r--r--server/controllers/api/config.ts1
-rw-r--r--server/controllers/api/users/email-verification.ts72
-rw-r--r--server/controllers/api/users/index.ts99
-rw-r--r--server/controllers/api/users/registrations.ts249
-rw-r--r--server/controllers/api/video-playlist.ts7
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/token.ts2
-rw-r--r--server/controllers/feeds.ts4
-rw-r--r--server/controllers/tracker.ts34
-rw-r--r--server/helpers/custom-validators/misc.ts8
-rw-r--r--server/helpers/custom-validators/user-registration.ts25
-rw-r--r--server/helpers/custom-validators/video-captions.ts9
-rw-r--r--server/helpers/custom-validators/video-imports.ts9
-rw-r--r--server/helpers/decache.ts2
-rw-r--r--server/helpers/memoize.ts12
-rw-r--r--server/helpers/youtube-dl/youtube-dl-cli.ts12
-rw-r--r--server/initializers/checker-after-init.ts15
-rw-r--r--server/initializers/checker-before-init.ts3
-rw-r--r--server/initializers/config.ts7
-rw-r--r--server/initializers/constants.ts31
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/initializers/installer.ts6
-rw-r--r--server/initializers/migrations/0750-user-registration.ts58
-rw-r--r--server/initializers/migrations/0755-unique-viewer-url.ts27
-rw-r--r--server/lib/auth/external-auth.ts72
-rw-r--r--server/lib/auth/oauth-model.ts75
-rw-r--r--server/lib/auth/oauth.ts43
-rw-r--r--server/lib/auth/tokens-cache.ts8
-rw-r--r--server/lib/emailer.ts54
-rw-r--r--server/lib/emails/common/base.pug12
-rw-r--r--server/lib/emails/user-registration-request-accepted/html.pug10
-rw-r--r--server/lib/emails/user-registration-request-rejected/html.pug9
-rw-r--r--server/lib/emails/user-registration-request/html.pug9
-rw-r--r--server/lib/emails/verify-email/html.pug26
-rw-r--r--server/lib/job-queue/job-queue.ts2
-rw-r--r--server/lib/notifier/notifier.ts19
-rw-r--r--server/lib/notifier/shared/instance/direct-registration-for-moderators.ts (renamed from server/lib/notifier/shared/instance/registration-for-moderators.ts)4
-rw-r--r--server/lib/notifier/shared/instance/index.ts3
-rw-r--r--server/lib/notifier/shared/instance/registration-request-for-moderators.ts48
-rw-r--r--server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts51
-rw-r--r--server/lib/opentelemetry/metric-helpers/index.ts1
-rw-r--r--server/lib/opentelemetry/metrics.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts6
-rw-r--r--server/lib/redis.ts45
-rw-r--r--server/lib/server-config-manager.ts12
-rw-r--r--server/lib/signup.ts15
-rw-r--r--server/lib/sync-channel.ts2
-rw-r--r--server/lib/user.ts38
-rw-r--r--server/lib/video-comment.ts33
-rw-r--r--server/lib/video-tokens-manager.ts22
-rw-r--r--server/middlewares/sort.ts23
-rw-r--r--server/middlewares/validators/config.ts1
-rw-r--r--server/middlewares/validators/index.ts2
-rw-r--r--server/middlewares/validators/shared/user-registrations.ts60
-rw-r--r--server/middlewares/validators/shared/users.ts4
-rw-r--r--server/middlewares/validators/shared/videos.ts12
-rw-r--r--server/middlewares/validators/sort.ts95
-rw-r--r--server/middlewares/validators/user-email-verification.ts94
-rw-r--r--server/middlewares/validators/user-registrations.ts208
-rw-r--r--server/middlewares/validators/users.ts151
-rw-r--r--server/models/abuse/abuse-message.ts2
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/sql/abuse-query-builder.ts (renamed from server/models/abuse/abuse-query-builder.ts)4
-rw-r--r--server/models/account/account-blocklist.ts2
-rw-r--r--server/models/account/account-video-rate.ts2
-rw-r--r--server/models/account/account.ts16
-rw-r--r--server/models/actor/actor-follow.ts16
-rw-r--r--server/models/actor/actor-image.ts14
-rw-r--r--server/models/actor/actor.ts27
-rw-r--r--server/models/actor/sql/instance-list-followers-query-builder.ts2
-rw-r--r--server/models/actor/sql/instance-list-following-query-builder.ts2
-rw-r--r--server/models/actor/sql/shared/actor-follow-table-attributes.ts65
-rw-r--r--server/models/actor/sql/shared/instance-list-follows-query-builder.ts2
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/server/plugin.ts2
-rw-r--r--server/models/server/server-blocklist.ts2
-rw-r--r--server/models/server/server.ts14
-rw-r--r--server/models/shared/index.ts4
-rw-r--r--server/models/shared/model-builder.ts27
-rw-r--r--server/models/shared/model-cache.ts (renamed from server/models/model-cache.ts)0
-rw-r--r--server/models/shared/query.ts75
-rw-r--r--server/models/shared/sequelize-helpers.ts39
-rw-r--r--server/models/shared/sort.ts146
-rw-r--r--server/models/shared/sql.ts68
-rw-r--r--server/models/shared/update.ts14
-rw-r--r--server/models/user/sql/user-notitication-list-query-builder.ts132
-rw-r--r--server/models/user/user-notification-setting.ts2
-rw-r--r--server/models/user/user-notification.ts28
-rw-r--r--server/models/user/user-registration.ts259
-rw-r--r--server/models/user/user.ts21
-rw-r--r--server/models/utils.ts317
-rw-r--r--server/models/video/formatter/video-format-utils.ts2
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts400
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts2
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts7
-rw-r--r--server/models/video/tag.ts2
-rw-r--r--server/models/video/video-blacklist.ts6
-rw-r--r--server/models/video/video-caption.ts2
-rw-r--r--server/models/video/video-change-ownership.ts2
-rw-r--r--server/models/video/video-channel-sync.ts2
-rw-r--r--server/models/video/video-channel.ts12
-rw-r--r--server/models/video/video-comment.ts458
-rw-r--r--server/models/video/video-file.ts13
-rw-r--r--server/models/video/video-import.ts2
-rw-r--r--server/models/video/video-playlist-element.ts41
-rw-r--r--server/models/video/video-playlist.ts12
-rw-r--r--server/models/video/video-share.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts7
-rw-r--r--server/models/video/video.ts7
-rw-r--r--server/models/view/local-video-viewer.ts4
-rw-r--r--server/tests/api/activitypub/cleaner.ts8
-rw-r--r--server/tests/api/check-params/config.ts2
-rw-r--r--server/tests/api/check-params/contact-form.ts13
-rw-r--r--server/tests/api/check-params/index.ts3
-rw-r--r--server/tests/api/check-params/redundancy.ts2
-rw-r--r--server/tests/api/check-params/registrations.ts433
-rw-r--r--server/tests/api/check-params/upload-quota.ts4
-rw-r--r--server/tests/api/check-params/users-admin.ts9
-rw-r--r--server/tests/api/check-params/users-emails.ts119
-rw-r--r--server/tests/api/check-params/users.ts255
-rw-r--r--server/tests/api/live/live-fast-restream.ts14
-rw-r--r--server/tests/api/notifications/index.ts1
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts55
-rw-r--r--server/tests/api/notifications/registrations-notifications.ts88
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts4
-rw-r--r--server/tests/api/server/config-defaults.ts4
-rw-r--r--server/tests/api/server/config.ts9
-rw-r--r--server/tests/api/server/contact-form.ts9
-rw-r--r--server/tests/api/server/email.ts77
-rw-r--r--server/tests/api/server/reverse-proxy.ts8
-rw-r--r--server/tests/api/users/index.ts4
-rw-r--r--server/tests/api/users/oauth.ts192
-rw-r--r--server/tests/api/users/registrations.ts415
-rw-r--r--server/tests/api/users/users-email-verification.ts (renamed from server/tests/api/users/users-verification.ts)39
-rw-r--r--server/tests/api/users/users.ts234
-rw-r--r--server/tests/api/videos/video-channel-syncs.ts3
-rw-r--r--server/tests/api/videos/video-comments.ts33
-rw-r--r--server/tests/api/videos/video-imports.ts2
-rw-r--r--server/tests/api/videos/video-playlists.ts15
-rw-r--r--server/tests/external-plugins/akismet.ts4
-rw-r--r--server/tests/external-plugins/auto-block-videos.ts2
-rw-r--r--server/tests/external-plugins/auto-mute.ts2
-rw-r--r--server/tests/feeds/feeds.ts8
-rw-r--r--server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-four/main.js6
-rw-r--r--server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js13
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js34
-rw-r--r--server/tests/helpers/index.ts1
-rw-r--r--server/tests/helpers/version.ts36
-rw-r--r--server/tests/plugins/action-hooks.ts2
-rw-r--r--server/tests/plugins/external-auth.ts42
-rw-r--r--server/tests/plugins/filter-hooks.ts96
-rw-r--r--server/tests/plugins/id-and-pass-auth.ts34
-rw-r--r--server/tests/plugins/plugin-helpers.ts12
-rw-r--r--server/tests/shared/notifications.ts45
-rw-r--r--server/tests/shared/videos.ts2
-rw-r--r--server/types/express.d.ts8
-rw-r--r--server/types/lib.d.ts12
-rw-r--r--server/types/models/user/index.ts1
-rw-r--r--server/types/models/user/user-notification.ts9
-rw-r--r--server/types/models/user/user-registration.ts15
-rw-r--r--server/types/plugins/register-server-auth.model.ts21
-rw-r--r--server/types/plugins/register-server-option.model.ts3
165 files changed, 4693 insertions, 2099 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 8e064fb5b..def320730 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
309 if (redirectIfNotOwned(video.url, res)) return 309 if (redirectIfNotOwned(video.url, res)) return
310 310
311 const handler = async (start: number, count: number) => { 311 const handler = async (start: number, count: number) => {
312 const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) 312 const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
313 313
314 return { 314 return {
315 total: result.total, 315 total: result.total,
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index f0fb43071..86434f382 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -193,6 +193,7 @@ function customConfig (): CustomConfig {
193 signup: { 193 signup: {
194 enabled: CONFIG.SIGNUP.ENABLED, 194 enabled: CONFIG.SIGNUP.ENABLED,
195 limit: CONFIG.SIGNUP.LIMIT, 195 limit: CONFIG.SIGNUP.LIMIT,
196 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
196 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION, 197 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
197 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE 198 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
198 }, 199 },
diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts
new file mode 100644
index 000000000..230aaa9af
--- /dev/null
+++ b/server/controllers/api/users/email-verification.ts
@@ -0,0 +1,72 @@
1import express from 'express'
2import { HttpStatusCode } from '@shared/models'
3import { CONFIG } from '../../../initializers/config'
4import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
5import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
6import {
7 registrationVerifyEmailValidator,
8 usersAskSendVerifyEmailValidator,
9 usersVerifyEmailValidator
10} from '../../../middlewares/validators'
11
12const askSendEmailLimiter = buildRateLimiter({
13 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
14 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
15})
16
17const emailVerificationRouter = express.Router()
18
19emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
20 askSendEmailLimiter,
21 asyncMiddleware(usersAskSendVerifyEmailValidator),
22 asyncMiddleware(reSendVerifyUserEmail)
23)
24
25emailVerificationRouter.post('/:id/verify-email',
26 asyncMiddleware(usersVerifyEmailValidator),
27 asyncMiddleware(verifyUserEmail)
28)
29
30emailVerificationRouter.post('/registrations/:registrationId/verify-email',
31 asyncMiddleware(registrationVerifyEmailValidator),
32 asyncMiddleware(verifyRegistrationEmail)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 emailVerificationRouter
39}
40
41async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
42 const user = res.locals.user
43 const registration = res.locals.userRegistration
44
45 if (user) await sendVerifyUserEmail(user)
46 else if (registration) await sendVerifyRegistrationEmail(registration)
47
48 return res.status(HttpStatusCode.NO_CONTENT_204).end()
49}
50
51async function verifyUserEmail (req: express.Request, res: express.Response) {
52 const user = res.locals.user
53 user.emailVerified = true
54
55 if (req.body.isPendingEmail === true) {
56 user.email = user.pendingEmail
57 user.pendingEmail = null
58 }
59
60 await user.save()
61
62 return res.status(HttpStatusCode.NO_CONTENT_204).end()
63}
64
65async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
66 const registration = res.locals.userRegistration
67 registration.emailVerified = true
68
69 await registration.save()
70
71 return res.status(HttpStatusCode.NO_CONTENT_204).end()
72}
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index a8677a1d3..5a5a12e82 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -4,26 +4,21 @@ import { Hooks } from '@server/lib/plugins/hooks'
4import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 4import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
5import { MUserAccountDefault } from '@server/types/models' 5import { MUserAccountDefault } from '@server/types/models'
6import { pick } from '@shared/core-utils' 6import { pick } from '@shared/core-utils'
7import { HttpStatusCode, UserCreate, UserCreateResult, UserRegister, UserRight, UserUpdate } from '@shared/models' 7import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
9import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' 10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
11import { CONFIG } from '../../../initializers/config'
12import { WEBSERVER } from '../../../initializers/constants' 11import { WEBSERVER } from '../../../initializers/constants'
13import { sequelizeTypescript } from '../../../initializers/database' 12import { sequelizeTypescript } from '../../../initializers/database'
14import { Emailer } from '../../../lib/emailer' 13import { Emailer } from '../../../lib/emailer'
15import { Notifier } from '../../../lib/notifier'
16import { Redis } from '../../../lib/redis' 14import { Redis } from '../../../lib/redis'
17import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user' 15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
18import { 16import {
19 adminUsersSortValidator, 17 adminUsersSortValidator,
20 asyncMiddleware, 18 asyncMiddleware,
21 asyncRetryTransactionMiddleware, 19 asyncRetryTransactionMiddleware,
22 authenticate, 20 authenticate,
23 buildRateLimiter,
24 ensureUserHasRight, 21 ensureUserHasRight,
25 ensureUserRegistrationAllowed,
26 ensureUserRegistrationAllowedForIP,
27 paginationValidator, 22 paginationValidator,
28 setDefaultPagination, 23 setDefaultPagination,
29 setDefaultSort, 24 setDefaultSort,
@@ -31,19 +26,17 @@ import {
31 usersAddValidator, 26 usersAddValidator,
32 usersGetValidator, 27 usersGetValidator,
33 usersListValidator, 28 usersListValidator,
34 usersRegisterValidator,
35 usersRemoveValidator, 29 usersRemoveValidator,
36 usersUpdateValidator 30 usersUpdateValidator
37} from '../../../middlewares' 31} from '../../../middlewares'
38import { 32import {
39 ensureCanModerateUser, 33 ensureCanModerateUser,
40 usersAskResetPasswordValidator, 34 usersAskResetPasswordValidator,
41 usersAskSendVerifyEmailValidator,
42 usersBlockingValidator, 35 usersBlockingValidator,
43 usersResetPasswordValidator, 36 usersResetPasswordValidator
44 usersVerifyEmailValidator
45} from '../../../middlewares/validators' 37} from '../../../middlewares/validators'
46import { UserModel } from '../../../models/user/user' 38import { UserModel } from '../../../models/user/user'
39import { emailVerificationRouter } from './email-verification'
47import { meRouter } from './me' 40import { meRouter } from './me'
48import { myAbusesRouter } from './my-abuses' 41import { myAbusesRouter } from './my-abuses'
49import { myBlocklistRouter } from './my-blocklist' 42import { myBlocklistRouter } from './my-blocklist'
@@ -51,22 +44,14 @@ import { myVideosHistoryRouter } from './my-history'
51import { myNotificationsRouter } from './my-notifications' 44import { myNotificationsRouter } from './my-notifications'
52import { mySubscriptionsRouter } from './my-subscriptions' 45import { mySubscriptionsRouter } from './my-subscriptions'
53import { myVideoPlaylistsRouter } from './my-video-playlists' 46import { myVideoPlaylistsRouter } from './my-video-playlists'
47import { registrationsRouter } from './registrations'
54import { twoFactorRouter } from './two-factor' 48import { twoFactorRouter } from './two-factor'
55 49
56const auditLogger = auditLoggerFactory('users') 50const auditLogger = auditLoggerFactory('users')
57 51
58const signupRateLimiter = buildRateLimiter({
59 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
60 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
61 skipFailedRequests: true
62})
63
64const askSendEmailLimiter = buildRateLimiter({
65 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
66 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
67})
68
69const usersRouter = express.Router() 52const usersRouter = express.Router()
53usersRouter.use('/', emailVerificationRouter)
54usersRouter.use('/', registrationsRouter)
70usersRouter.use('/', twoFactorRouter) 55usersRouter.use('/', twoFactorRouter)
71usersRouter.use('/', tokensRouter) 56usersRouter.use('/', tokensRouter)
72usersRouter.use('/', myNotificationsRouter) 57usersRouter.use('/', myNotificationsRouter)
@@ -122,14 +107,6 @@ usersRouter.post('/',
122 asyncRetryTransactionMiddleware(createUser) 107 asyncRetryTransactionMiddleware(createUser)
123) 108)
124 109
125usersRouter.post('/register',
126 signupRateLimiter,
127 asyncMiddleware(ensureUserRegistrationAllowed),
128 ensureUserRegistrationAllowedForIP,
129 asyncMiddleware(usersRegisterValidator),
130 asyncRetryTransactionMiddleware(registerUser)
131)
132
133usersRouter.put('/:id', 110usersRouter.put('/:id',
134 authenticate, 111 authenticate,
135 ensureUserHasRight(UserRight.MANAGE_USERS), 112 ensureUserHasRight(UserRight.MANAGE_USERS),
@@ -156,17 +133,6 @@ usersRouter.post('/:id/reset-password',
156 asyncMiddleware(resetUserPassword) 133 asyncMiddleware(resetUserPassword)
157) 134)
158 135
159usersRouter.post('/ask-send-verify-email',
160 askSendEmailLimiter,
161 asyncMiddleware(usersAskSendVerifyEmailValidator),
162 asyncMiddleware(reSendVerifyUserEmail)
163)
164
165usersRouter.post('/:id/verify-email',
166 asyncMiddleware(usersVerifyEmailValidator),
167 asyncMiddleware(verifyUserEmail)
168)
169
170// --------------------------------------------------------------------------- 136// ---------------------------------------------------------------------------
171 137
172export { 138export {
@@ -218,35 +184,6 @@ async function createUser (req: express.Request, res: express.Response) {
218 }) 184 })
219} 185}
220 186
221async function registerUser (req: express.Request, res: express.Response) {
222 const body: UserRegister = req.body
223
224 const userToCreate = buildUser({
225 ...pick(body, [ 'username', 'password', 'email' ]),
226
227 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
228 })
229
230 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
231 userToCreate,
232 userDisplayName: body.displayName || undefined,
233 channelNames: body.channel
234 })
235
236 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
237 logger.info('User %s with its channel and account registered.', body.username)
238
239 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
240 await sendVerifyUserEmail(user)
241 }
242
243 Notifier.Instance.notifyOnNewUserRegistration(user)
244
245 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
246
247 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
248}
249
250async function unblockUser (req: express.Request, res: express.Response) { 187async function unblockUser (req: express.Request, res: express.Response) {
251 const user = res.locals.user 188 const user = res.locals.user
252 189
@@ -360,28 +297,6 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
360 return res.status(HttpStatusCode.NO_CONTENT_204).end() 297 return res.status(HttpStatusCode.NO_CONTENT_204).end()
361} 298}
362 299
363async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
364 const user = res.locals.user
365
366 await sendVerifyUserEmail(user)
367
368 return res.status(HttpStatusCode.NO_CONTENT_204).end()
369}
370
371async function verifyUserEmail (req: express.Request, res: express.Response) {
372 const user = res.locals.user
373 user.emailVerified = true
374
375 if (req.body.isPendingEmail === true) {
376 user.email = user.pendingEmail
377 user.pendingEmail = null
378 }
379
380 await user.save()
381
382 return res.status(HttpStatusCode.NO_CONTENT_204).end()
383}
384
385async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) { 300async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
386 const oldUserAuditView = new UserAuditView(user.toFormattedJSON()) 301 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
387 302
diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts
new file mode 100644
index 000000000..5e213d6cc
--- /dev/null
+++ b/server/controllers/api/users/registrations.ts
@@ -0,0 +1,249 @@
1import express from 'express'
2import { Emailer } from '@server/lib/emailer'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { UserRegistrationModel } from '@server/models/user/user-registration'
5import { pick } from '@shared/core-utils'
6import {
7 HttpStatusCode,
8 UserRegister,
9 UserRegistrationRequest,
10 UserRegistrationState,
11 UserRegistrationUpdateState,
12 UserRight
13} from '@shared/models'
14import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
15import { logger } from '../../../helpers/logger'
16import { CONFIG } from '../../../initializers/config'
17import { Notifier } from '../../../lib/notifier'
18import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
19import {
20 acceptOrRejectRegistrationValidator,
21 asyncMiddleware,
22 asyncRetryTransactionMiddleware,
23 authenticate,
24 buildRateLimiter,
25 ensureUserHasRight,
26 ensureUserRegistrationAllowedFactory,
27 ensureUserRegistrationAllowedForIP,
28 getRegistrationValidator,
29 listRegistrationsValidator,
30 paginationValidator,
31 setDefaultPagination,
32 setDefaultSort,
33 userRegistrationsSortValidator,
34 usersDirectRegistrationValidator,
35 usersRequestRegistrationValidator
36} from '../../../middlewares'
37
38const auditLogger = auditLoggerFactory('users')
39
40const registrationRateLimiter = buildRateLimiter({
41 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
42 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
43 skipFailedRequests: true
44})
45
46const registrationsRouter = express.Router()
47
48registrationsRouter.post('/registrations/request',
49 registrationRateLimiter,
50 asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
51 ensureUserRegistrationAllowedForIP,
52 asyncMiddleware(usersRequestRegistrationValidator),
53 asyncRetryTransactionMiddleware(requestRegistration)
54)
55
56registrationsRouter.post('/registrations/:registrationId/accept',
57 authenticate,
58 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
59 asyncMiddleware(acceptOrRejectRegistrationValidator),
60 asyncRetryTransactionMiddleware(acceptRegistration)
61)
62registrationsRouter.post('/registrations/:registrationId/reject',
63 authenticate,
64 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
65 asyncMiddleware(acceptOrRejectRegistrationValidator),
66 asyncRetryTransactionMiddleware(rejectRegistration)
67)
68
69registrationsRouter.delete('/registrations/:registrationId',
70 authenticate,
71 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
72 asyncMiddleware(getRegistrationValidator),
73 asyncRetryTransactionMiddleware(deleteRegistration)
74)
75
76registrationsRouter.get('/registrations',
77 authenticate,
78 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
79 paginationValidator,
80 userRegistrationsSortValidator,
81 setDefaultSort,
82 setDefaultPagination,
83 listRegistrationsValidator,
84 asyncMiddleware(listRegistrations)
85)
86
87registrationsRouter.post('/register',
88 registrationRateLimiter,
89 asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
90 ensureUserRegistrationAllowedForIP,
91 asyncMiddleware(usersDirectRegistrationValidator),
92 asyncRetryTransactionMiddleware(registerUser)
93)
94
95// ---------------------------------------------------------------------------
96
97export {
98 registrationsRouter
99}
100
101// ---------------------------------------------------------------------------
102
103async function requestRegistration (req: express.Request, res: express.Response) {
104 const body: UserRegistrationRequest = req.body
105
106 const registration = new UserRegistrationModel({
107 ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
108
109 accountDisplayName: body.displayName,
110 channelDisplayName: body.channel?.displayName,
111 channelHandle: body.channel?.name,
112
113 state: UserRegistrationState.PENDING,
114
115 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
116 })
117
118 await registration.save()
119
120 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
121 await sendVerifyRegistrationEmail(registration)
122 }
123
124 Notifier.Instance.notifyOnNewRegistrationRequest(registration)
125
126 Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
127
128 return res.json(registration.toFormattedJSON())
129}
130
131// ---------------------------------------------------------------------------
132
133async function acceptRegistration (req: express.Request, res: express.Response) {
134 const registration = res.locals.userRegistration
135 const body: UserRegistrationUpdateState = req.body
136
137 const userToCreate = buildUser({
138 username: registration.username,
139 password: registration.password,
140 email: registration.email,
141 emailVerified: registration.emailVerified
142 })
143 // We already encrypted password in registration model
144 userToCreate.skipPasswordEncryption = true
145
146 // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
147
148 const { user } = await createUserAccountAndChannelAndPlaylist({
149 userToCreate,
150 userDisplayName: registration.accountDisplayName,
151 channelNames: registration.channelHandle && registration.channelDisplayName
152 ? {
153 name: registration.channelHandle,
154 displayName: registration.channelDisplayName
155 }
156 : undefined
157 })
158
159 registration.userId = user.id
160 registration.state = UserRegistrationState.ACCEPTED
161 registration.moderationResponse = body.moderationResponse
162
163 await registration.save()
164
165 logger.info('Registration of %s accepted', registration.username)
166
167 if (body.preventEmailDelivery !== true) {
168 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
169 }
170
171 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
172}
173
174async function rejectRegistration (req: express.Request, res: express.Response) {
175 const registration = res.locals.userRegistration
176 const body: UserRegistrationUpdateState = req.body
177
178 registration.state = UserRegistrationState.REJECTED
179 registration.moderationResponse = body.moderationResponse
180
181 await registration.save()
182
183 if (body.preventEmailDelivery !== true) {
184 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
185 }
186
187 logger.info('Registration of %s rejected', registration.username)
188
189 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
190}
191
192// ---------------------------------------------------------------------------
193
194async function deleteRegistration (req: express.Request, res: express.Response) {
195 const registration = res.locals.userRegistration
196
197 await registration.destroy()
198
199 logger.info('Registration of %s deleted', registration.username)
200
201 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
202}
203
204// ---------------------------------------------------------------------------
205
206async function listRegistrations (req: express.Request, res: express.Response) {
207 const resultList = await UserRegistrationModel.listForApi({
208 start: req.query.start,
209 count: req.query.count,
210 sort: req.query.sort,
211 search: req.query.search
212 })
213
214 return res.json({
215 total: resultList.total,
216 data: resultList.data.map(d => d.toFormattedJSON())
217 })
218}
219
220// ---------------------------------------------------------------------------
221
222async function registerUser (req: express.Request, res: express.Response) {
223 const body: UserRegister = req.body
224
225 const userToCreate = buildUser({
226 ...pick(body, [ 'username', 'password', 'email' ]),
227
228 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
229 })
230
231 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
232 userToCreate,
233 userDisplayName: body.displayName || undefined,
234 channelNames: body.channel
235 })
236
237 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
238 logger.info('User %s with its channel and account registered.', body.username)
239
240 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
241 await sendVerifyUserEmail(user)
242 }
243
244 Notifier.Instance.notifyOnNewDirectRegistration(user)
245
246 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
247
248 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
249}
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index f8a607170..947f7ca77 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -15,7 +15,7 @@ import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/vid
15import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model' 15import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
16import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model' 16import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
17import { resetSequelizeInstance } from '../../helpers/database-utils' 17import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils' 18import { createReqFiles } from '../../helpers/express-utils'
19import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
20import { getFormattedObjects } from '../../helpers/utils' 20import { getFormattedObjects } from '../../helpers/utils'
21import { CONFIG } from '../../initializers/config' 21import { CONFIG } from '../../initializers/config'
@@ -474,10 +474,7 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
474 'filter:api.video-playlist.videos.list.result' 474 'filter:api.video-playlist.videos.list.result'
475 ) 475 )
476 476
477 const options = { 477 const options = { accountId: user?.Account?.id }
478 displayNSFW: buildNSFWFilter(res, req.query.nsfw),
479 accountId: user ? user.Account.id : undefined
480 }
481 return res.json(getFormattedObjects(resultList.data, resultList.total, options)) 478 return res.json(getFormattedObjects(resultList.data, resultList.total, options))
482} 479}
483 480
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 44d64776c..70ca21500 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,4 +1,6 @@
1import { MCommentFormattable } from '@server/types/models'
1import express from 'express' 2import express from 'express'
3
2import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' 4import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' 6import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
109 const video = res.locals.onlyVideo 111 const video = res.locals.onlyVideo
110 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
111 113
112 let resultList: ThreadsResultList<VideoCommentModel> 114 let resultList: ThreadsResultList<MCommentFormattable>
113 115
114 if (video.commentsEnabled === true) { 116 if (video.commentsEnabled === true) {
115 const apiOptions = await Hooks.wrapObject({ 117 const apiOptions = await Hooks.wrapObject({
@@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
144 const video = res.locals.onlyVideo 146 const video = res.locals.onlyVideo
145 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 147 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
146 148
147 let resultList: ResultList<VideoCommentModel> 149 let resultList: ResultList<MCommentFormattable>
148 150
149 if (video.commentsEnabled === true) { 151 if (video.commentsEnabled === true) {
150 const apiOptions = await Hooks.wrapObject({ 152 const apiOptions = await Hooks.wrapObject({
151 videoId: video.id, 153 videoId: video.id,
152 isVideoOwned: video.isOwned(),
153 threadId: res.locals.videoCommentThread.id, 154 threadId: res.locals.videoCommentThread.id,
154 user 155 user
155 }, 'filter:api.video-thread-comments.list.params') 156 }, 'filter:api.video-thread-comments.list.params')
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 009b6dfb6..22387c3e8 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -22,7 +22,7 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 22function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 23 const video = res.locals.onlyVideo
24 24
25 const { token, expires } = VideoTokensManager.Instance.create(video.uuid) 25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 26
27 return res.json({ 27 return res.json({
28 files: { 28 files: {
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 772fe734d..ef810a842 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -285,8 +285,8 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
285 content: toSafeHtml(video.description), 285 content: toSafeHtml(video.description),
286 author: [ 286 author: [
287 { 287 {
288 name: video.VideoChannel.Account.getDisplayName(), 288 name: video.VideoChannel.getDisplayName(),
289 link: video.VideoChannel.Account.Actor.url 289 link: video.VideoChannel.Actor.url
290 } 290 }
291 ], 291 ],
292 date: video.publishedAt, 292 date: video.publishedAt,
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 19a8b2bc9..c4f3a8889 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -1,17 +1,22 @@
1import { Server as TrackerServer } from 'bittorrent-tracker' 1import { Server as TrackerServer } from 'bittorrent-tracker'
2import express from 'express' 2import express from 'express'
3import { createServer } from 'http' 3import { createServer } from 'http'
4import LRUCache from 'lru-cache'
4import proxyAddr from 'proxy-addr' 5import proxyAddr from 'proxy-addr'
5import { WebSocketServer } from 'ws' 6import { WebSocketServer } from 'ws'
6import { Redis } from '@server/lib/redis'
7import { logger } from '../helpers/logger' 7import { logger } from '../helpers/logger'
8import { CONFIG } from '../initializers/config' 8import { CONFIG } from '../initializers/config'
9import { TRACKER_RATE_LIMITS } from '../initializers/constants' 9import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
10import { VideoFileModel } from '../models/video/video-file' 10import { VideoFileModel } from '../models/video/video-file'
11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
12 12
13const trackerRouter = express.Router() 13const trackerRouter = express.Router()
14 14
15const blockedIPs = new LRUCache<string, boolean>({
16 max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
17 ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
18})
19
15let peersIps = {} 20let peersIps = {}
16let peersIpInfoHash = {} 21let peersIpInfoHash = {}
17runPeersChecker() 22runPeersChecker()
@@ -55,8 +60,7 @@ const trackerServer = new TrackerServer({
55 60
56 // Close socket connection and block IP for a few time 61 // Close socket connection and block IP for a few time
57 if (params.type === 'ws') { 62 if (params.type === 'ws') {
58 Redis.Instance.setTrackerBlockIP(ip) 63 blockedIPs.set(ip, true)
59 .catch(err => logger.error('Cannot set tracker block ip.', { err }))
60 64
61 // setTimeout to wait filter response 65 // setTimeout to wait filter response
62 setTimeout(() => params.socket.close(), 0) 66 setTimeout(() => params.socket.close(), 0)
@@ -102,26 +106,22 @@ function createWebsocketTrackerServer (app: express.Application) {
102 if (request.url === '/tracker/socket') { 106 if (request.url === '/tracker/socket') {
103 const ip = proxyAddr(request, CONFIG.TRUST_PROXY) 107 const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
104 108
105 Redis.Instance.doesTrackerBlockIPExist(ip) 109 if (blockedIPs.has(ip)) {
106 .then(result => { 110 logger.debug('Blocking IP %s from tracker.', ip)
107 if (result === true) {
108 logger.debug('Blocking IP %s from tracker.', ip)
109 111
110 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') 112 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
111 socket.destroy() 113 socket.destroy()
112 return 114 return
113 } 115 }
114 116
115 // FIXME: typings 117 // FIXME: typings
116 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request)) 118 return wss.handleUpgrade(request, socket as any, head, ws => wss.emit('connection', ws, request))
117 })
118 .catch(err => logger.error('Cannot check if tracker block ip exists.', { err }))
119 } 119 }
120 120
121 // Don't destroy socket, we have Socket.IO too 121 // Don't destroy socket, we have Socket.IO too
122 }) 122 })
123 123
124 return server 124 return { server, trackerServer }
125} 125}
126 126
127// --------------------------------------------------------------------------- 127// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 3dc5504e3..b3ab3ac64 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -103,7 +103,13 @@ function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
103// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
104 104
105function toCompleteUUID (value: string) { 105function toCompleteUUID (value: string) {
106 if (isShortUUID(value)) return shortToUUID(value) 106 if (isShortUUID(value)) {
107 try {
108 return shortToUUID(value)
109 } catch {
110 return null
111 }
112 }
107 113
108 return value 114 return value
109} 115}
diff --git a/server/helpers/custom-validators/user-registration.ts b/server/helpers/custom-validators/user-registration.ts
new file mode 100644
index 000000000..9da0bb08a
--- /dev/null
+++ b/server/helpers/custom-validators/user-registration.ts
@@ -0,0 +1,25 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS, USER_REGISTRATION_STATES } from '../../initializers/constants'
3import { exists } from './misc'
4
5const USER_REGISTRATIONS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USER_REGISTRATIONS
6
7function isRegistrationStateValid (value: string) {
8 return exists(value) && USER_REGISTRATION_STATES[value] !== undefined
9}
10
11function isRegistrationModerationResponseValid (value: string) {
12 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.MODERATOR_MESSAGE)
13}
14
15function isRegistrationReasonValid (value: string) {
16 return exists(value) && validator.isLength(value, USER_REGISTRATIONS_CONSTRAINTS_FIELDS.REASON_MESSAGE)
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 isRegistrationStateValid,
23 isRegistrationModerationResponseValid,
24 isRegistrationReasonValid
25}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 59ba005fe..d5b09ea03 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined
9} 9}
10 10
11const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) 11// MacOS sends application/octet-stream
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14 .join('|') 14 .join('|')
15
15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { 16function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
16 return isFileValid({ 17 return isFileValid({
17 files, 18 files,
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index af93aea56..da8962cb6 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) {
22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined 22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
23} 23}
24 24
25const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) 25// MacOS sends application/octet-stream
26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
27 .map(m => `(${m})`) 27 .map(m => `(${m})`)
28 .join('|') 28 .join('|')
29
29function isVideoImportTorrentFile (files: UploadFilesForCheck) { 30function isVideoImportTorrentFile (files: UploadFilesForCheck) {
30 return isFileValid({ 31 return isFileValid({
31 files, 32 files,
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
index e31973b7a..08ab545e4 100644
--- a/server/helpers/decache.ts
+++ b/server/helpers/decache.ts
@@ -68,7 +68,7 @@ function searchCache (moduleName: string, callback: (current: NodeModule) => voi
68}; 68};
69 69
70function removeCachedPath (pluginPath: string) { 70function removeCachedPath (pluginPath: string) {
71 const pathCache = (module.constructor as any)._pathCache 71 const pathCache = (module.constructor as any)._pathCache as { [ id: string ]: string[] }
72 72
73 Object.keys(pathCache).forEach(function (cacheKey) { 73 Object.keys(pathCache).forEach(function (cacheKey) {
74 if (cacheKey.includes(pluginPath)) { 74 if (cacheKey.includes(pluginPath)) {
diff --git a/server/helpers/memoize.ts b/server/helpers/memoize.ts
new file mode 100644
index 000000000..aa20e7d73
--- /dev/null
+++ b/server/helpers/memoize.ts
@@ -0,0 +1,12 @@
1import memoizee from 'memoizee'
2
3export function Memoize (config?: memoizee.Options<any>) {
4 return function (_target, _key, descriptor: PropertyDescriptor) {
5 const oldFunction = descriptor.value
6 const newFunction = memoizee(oldFunction, config)
7
8 descriptor.value = function () {
9 return newFunction.apply(this, arguments)
10 }
11 }
12}
diff --git a/server/helpers/youtube-dl/youtube-dl-cli.ts b/server/helpers/youtube-dl/youtube-dl-cli.ts
index a2f630953..765038cea 100644
--- a/server/helpers/youtube-dl/youtube-dl-cli.ts
+++ b/server/helpers/youtube-dl/youtube-dl-cli.ts
@@ -6,6 +6,7 @@ import { VideoResolution } from '@shared/models'
6import { logger, loggerTagsFactory } from '../logger' 6import { logger, loggerTagsFactory } from '../logger'
7import { getProxy, isProxyEnabled } from '../proxy' 7import { getProxy, isProxyEnabled } from '../proxy'
8import { isBinaryResponse, peertubeGot } from '../requests' 8import { isBinaryResponse, peertubeGot } from '../requests'
9import { OptionsOfBufferResponseBody } from 'got/dist/source'
9 10
10const lTags = loggerTagsFactory('youtube-dl') 11const lTags = loggerTagsFactory('youtube-dl')
11 12
@@ -28,7 +29,16 @@ export class YoutubeDLCLI {
28 29
29 logger.info('Updating youtubeDL binary from %s.', url, lTags()) 30 logger.info('Updating youtubeDL binary from %s.', url, lTags())
30 31
31 const gotOptions = { context: { bodyKBLimit: 20_000 }, responseType: 'buffer' as 'buffer' } 32 const gotOptions: OptionsOfBufferResponseBody = {
33 context: { bodyKBLimit: 20_000 },
34 responseType: 'buffer' as 'buffer'
35 }
36
37 if (process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN) {
38 gotOptions.headers = {
39 authorization: 'Bearer ' + process.env.YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN
40 }
41 }
32 42
33 try { 43 try {
34 let gotResult = await peertubeGot(url, gotOptions) 44 let gotResult = await peertubeGot(url, gotOptions)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index c83fef425..0df7414be 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -4,7 +4,7 @@ import { getFFmpegVersion } from '@server/helpers/ffmpeg'
4import { uniqify } from '@shared/core-utils' 4import { uniqify } from '@shared/core-utils'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { RecentlyAddedStrategy } from '../../shared/models/redundancy' 6import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
7import { isProdInstance, parseSemVersion } from '../helpers/core-utils' 7import { isProdInstance, parseBytes, parseSemVersion } from '../helpers/core-utils'
8import { isArray } from '../helpers/custom-validators/misc' 8import { isArray } from '../helpers/custom-validators/misc'
9import { logger } from '../helpers/logger' 9import { logger } from '../helpers/logger'
10import { ApplicationModel, getServerActor } from '../models/application/application' 10import { ApplicationModel, getServerActor } from '../models/application/application'
@@ -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 }
@@ -174,7 +179,8 @@ function checkRemoteRedundancyConfig () {
174function checkStorageConfig () { 179function checkStorageConfig () {
175 // Check storage directory locations 180 // Check storage directory locations
176 if (isProdInstance()) { 181 if (isProdInstance()) {
177 const configStorage = config.get('storage') 182 const configStorage = config.get<{ [ name: string ]: string }>('storage')
183
178 for (const key of Object.keys(configStorage)) { 184 for (const key of Object.keys(configStorage)) {
179 if (configStorage[key].startsWith('storage/')) { 185 if (configStorage[key].startsWith('storage/')) {
180 logger.warn( 186 logger.warn(
@@ -278,6 +284,11 @@ function checkObjectStorageConfig () {
278 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.' 284 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
279 ) 285 )
280 } 286 }
287
288 if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) {
289 // eslint-disable-next-line max-len
290 logger.warn(`Object storage max upload part seems to have a big value (${CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART} bytes). Consider using a lower one (like 100MB).`)
291 }
281 } 292 }
282} 293}
283 294
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 39713a266..8b4d49180 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -13,6 +13,7 @@ function checkMissedConfig () {
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube', 14 'secrets.peertube',
15 'trust_proxy', 15 'trust_proxy',
16 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 17 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 18 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
18 'email.body.signature', 'email.subject.prefix', 19 'email.body.signature', 'email.subject.prefix',
@@ -27,7 +28,7 @@ function checkMissedConfig () {
27 'csp.enabled', 'csp.report_only', 'csp.report_uri', 28 'csp.enabled', 'csp.report_only', 'csp.report_uri',
28 'security.frameguard.enabled', 29 'security.frameguard.enabled',
29 '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',
30 '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',
31 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 32 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
32 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 33 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
33 '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 c2f8b19fd..9685e7bfc 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -149,6 +149,12 @@ const CONFIG = {
149 HOSTNAME: config.get<string>('webserver.hostname'), 149 HOSTNAME: config.get<string>('webserver.hostname'),
150 PORT: config.get<number>('webserver.port') 150 PORT: config.get<number>('webserver.port')
151 }, 151 },
152 OAUTH2: {
153 TOKEN_LIFETIME: {
154 ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
155 REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
156 }
157 },
152 RATES_LIMIT: { 158 RATES_LIMIT: {
153 API: { 159 API: {
154 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), 160 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')),
@@ -299,6 +305,7 @@ const CONFIG = {
299 }, 305 },
300 SIGNUP: { 306 SIGNUP: {
301 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') },
302 get LIMIT () { return config.get<number>('signup.limit') }, 309 get LIMIT () { return config.get<number>('signup.limit') },
303 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') },
304 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 0e56f0c9f..992c86ed2 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -6,6 +6,7 @@ import { randomInt, root } from '@shared/core-utils'
6import { 6import {
7 AbuseState, 7 AbuseState,
8 JobType, 8 JobType,
9 UserRegistrationState,
9 VideoChannelSyncState, 10 VideoChannelSyncState,
10 VideoImportState, 11 VideoImportState,
11 VideoPrivacy, 12 VideoPrivacy,
@@ -25,7 +26,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
25 26
26// --------------------------------------------------------------------------- 27// ---------------------------------------------------------------------------
27 28
28const LAST_MIGRATION_VERSION = 745 29const LAST_MIGRATION_VERSION = 755
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
@@ -101,11 +104,6 @@ const SORTABLE_COLUMNS = {
101 VIDEO_REDUNDANCIES: [ 'name' ] 104 VIDEO_REDUNDANCIES: [ 'name' ]
102} 105}
103 106
104const OAUTH_LIFETIME = {
105 ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
106 REFRESH_TOKEN: 1209600 // 2 weeks
107}
108
109const ROUTE_CACHE_LIFETIME = { 107const ROUTE_CACHE_LIFETIME = {
110 FEEDS: '15 minutes', 108 FEEDS: '15 minutes',
111 ROBOTS: '2 hours', 109 ROBOTS: '2 hours',
@@ -295,6 +293,10 @@ const CONSTRAINTS_FIELDS = {
295 ABUSE_MESSAGES: { 293 ABUSE_MESSAGES: {
296 MESSAGE: { min: 2, max: 3000 } // Length 294 MESSAGE: { min: 2, max: 3000 } // Length
297 }, 295 },
296 USER_REGISTRATIONS: {
297 REASON_MESSAGE: { min: 2, max: 3000 }, // Length
298 MODERATOR_MESSAGE: { min: 2, max: 3000 } // Length
299 },
298 VIDEO_BLACKLIST: { 300 VIDEO_BLACKLIST: {
299 REASON: { min: 2, max: 300 } // Length 301 REASON: { min: 2, max: 300 } // Length
300 }, 302 },
@@ -521,6 +523,12 @@ const ABUSE_STATES: { [ id in AbuseState ]: string } = {
521 [AbuseState.ACCEPTED]: 'Accepted' 523 [AbuseState.ACCEPTED]: 'Accepted'
522} 524}
523 525
526const USER_REGISTRATION_STATES: { [ id in UserRegistrationState ]: string } = {
527 [UserRegistrationState.PENDING]: 'Pending',
528 [UserRegistrationState.REJECTED]: 'Rejected',
529 [UserRegistrationState.ACCEPTED]: 'Accepted'
530}
531
524const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = { 532const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
525 [VideoPlaylistPrivacy.PUBLIC]: 'Public', 533 [VideoPlaylistPrivacy.PUBLIC]: 'Public',
526 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', 534 [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
@@ -665,7 +673,7 @@ const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
665 673
666const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes 674const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
667 675
668const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes 676const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
669 677
670const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { 678const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
671 DO_NOT_LIST: 'do_not_list', 679 DO_NOT_LIST: 'do_not_list',
@@ -781,6 +789,9 @@ const LRU_CACHE = {
781 VIDEO_TOKENS: { 789 VIDEO_TOKENS: {
782 MAX_SIZE: 100_000, 790 MAX_SIZE: 100_000,
783 TTL: parseDurationToMs('8 hours') 791 TTL: parseDurationToMs('8 hours')
792 },
793 TRACKER_IPS: {
794 MAX_SIZE: 100_000
784 } 795 }
785} 796}
786 797
@@ -884,7 +895,7 @@ const TRACKER_RATE_LIMITS = {
884 INTERVAL: 60000 * 5, // 5 minutes 895 INTERVAL: 60000 * 5, // 5 minutes
885 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval 896 ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval
886 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval 897 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
887 BLOCK_IP_LIFETIME: 60000 * 3 // 3 minutes 898 BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes')
888} 899}
889 900
890const P2P_MEDIA_LOADER_PEER_VERSION = 2 901const P2P_MEDIA_LOADER_PEER_VERSION = 2
@@ -1030,7 +1041,6 @@ export {
1030 JOB_ATTEMPTS, 1041 JOB_ATTEMPTS,
1031 AP_CLEANER, 1042 AP_CLEANER,
1032 LAST_MIGRATION_VERSION, 1043 LAST_MIGRATION_VERSION,
1033 OAUTH_LIFETIME,
1034 CUSTOM_HTML_TAG_COMMENTS, 1044 CUSTOM_HTML_TAG_COMMENTS,
1035 STATS_TIMESERIE, 1045 STATS_TIMESERIE,
1036 BROADCAST_CONCURRENCY, 1046 BROADCAST_CONCURRENCY,
@@ -1072,13 +1082,14 @@ export {
1072 VIDEO_TRANSCODING_FPS, 1082 VIDEO_TRANSCODING_FPS,
1073 FFMPEG_NICE, 1083 FFMPEG_NICE,
1074 ABUSE_STATES, 1084 ABUSE_STATES,
1085 USER_REGISTRATION_STATES,
1075 LRU_CACHE, 1086 LRU_CACHE,
1076 REQUEST_TIMEOUTS, 1087 REQUEST_TIMEOUTS,
1077 MAX_LOCAL_VIEWER_WATCH_SECTIONS, 1088 MAX_LOCAL_VIEWER_WATCH_SECTIONS,
1078 USER_PASSWORD_RESET_LIFETIME, 1089 USER_PASSWORD_RESET_LIFETIME,
1079 USER_PASSWORD_CREATE_LIFETIME, 1090 USER_PASSWORD_CREATE_LIFETIME,
1080 MEMOIZE_TTL, 1091 MEMOIZE_TTL,
1081 USER_EMAIL_VERIFY_LIFETIME, 1092 EMAIL_VERIFY_LIFETIME,
1082 OVERVIEWS, 1093 OVERVIEWS,
1083 SCHEDULER_INTERVALS_MS, 1094 SCHEDULER_INTERVALS_MS,
1084 REPEAT_JOBS, 1095 REPEAT_JOBS,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index f55f40df0..96145f489 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -5,7 +5,9 @@ import { TrackerModel } from '@server/models/server/tracker'
5import { VideoTrackerModel } from '@server/models/server/video-tracker' 5import { VideoTrackerModel } from '@server/models/server/video-tracker'
6import { UserModel } from '@server/models/user/user' 6import { UserModel } from '@server/models/user/user'
7import { UserNotificationModel } from '@server/models/user/user-notification' 7import { UserNotificationModel } from '@server/models/user/user-notification'
8import { UserRegistrationModel } from '@server/models/user/user-registration'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 9import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
10import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info' 11import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 12import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
11import { VideoSourceModel } from '@server/models/video/video-source' 13import { VideoSourceModel } from '@server/models/video/video-source'
@@ -50,7 +52,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
50import { VideoTagModel } from '../models/video/video-tag' 52import { VideoTagModel } from '../models/video/video-tag'
51import { VideoViewModel } from '../models/view/video-view' 53import { VideoViewModel } from '../models/view/video-view'
52import { CONFIG } from './config' 54import { CONFIG } from './config'
53import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
54 55
55require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 56require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
56 57
@@ -155,7 +156,8 @@ async function initDatabaseModels (silent: boolean) {
155 PluginModel, 156 PluginModel,
156 ActorCustomPageModel, 157 ActorCustomPageModel,
157 VideoJobInfoModel, 158 VideoJobInfoModel,
158 VideoChannelSyncModel 159 VideoChannelSyncModel,
160 UserRegistrationModel
159 ]) 161 ])
160 162
161 // Check extensions exist in the database 163 // Check extensions exist in the database
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index f5d8eedf1..f48f348a7 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
51 const tasks: Promise<any>[] = [] 51 const tasks: Promise<any>[] = []
52 52
53 // Cache directories 53 // Cache directories
54 for (const key of Object.keys(cacheDirectories)) { 54 for (const dir of cacheDirectories) {
55 const dir = cacheDirectories[key]
56 tasks.push(removeDirectoryOrContent(dir)) 55 tasks.push(removeDirectoryOrContent(dir))
57 } 56 }
58 57
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
87 } 86 }
88 87
89 // Cache directories 88 // Cache directories
90 for (const key of Object.keys(cacheDirectories)) { 89 for (const dir of cacheDirectories) {
91 const dir = cacheDirectories[key]
92 tasks.push(ensureDir(dir)) 90 tasks.push(ensureDir(dir))
93 } 91 }
94 92
diff --git a/server/initializers/migrations/0750-user-registration.ts b/server/initializers/migrations/0750-user-registration.ts
new file mode 100644
index 000000000..15bbfd3fd
--- /dev/null
+++ b/server/initializers/migrations/0750-user-registration.ts
@@ -0,0 +1,58 @@
1
2import * as Sequelize from 'sequelize'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction
6 queryInterface: Sequelize.QueryInterface
7 sequelize: Sequelize.Sequelize
8 db: any
9}): Promise<void> {
10 {
11 const query = `
12 CREATE TABLE IF NOT EXISTS "userRegistration" (
13 "id" serial,
14 "state" integer NOT NULL,
15 "registrationReason" text NOT NULL,
16 "moderationResponse" text,
17 "password" varchar(255),
18 "username" varchar(255) NOT NULL,
19 "email" varchar(400) NOT NULL,
20 "emailVerified" boolean,
21 "accountDisplayName" varchar(255),
22 "channelHandle" varchar(255),
23 "channelDisplayName" varchar(255),
24 "userId" integer REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
25 "createdAt" timestamp with time zone NOT NULL,
26 "updatedAt" timestamp with time zone NOT NULL,
27 PRIMARY KEY ("id")
28 );
29 `
30 await utils.sequelize.query(query, { transaction: utils.transaction })
31 }
32
33 {
34 await utils.queryInterface.addColumn('userNotification', 'userRegistrationId', {
35 type: Sequelize.INTEGER,
36 defaultValue: null,
37 allowNull: true,
38 references: {
39 model: 'userRegistration',
40 key: 'id'
41 },
42 onUpdate: 'CASCADE',
43 onDelete: 'SET NULL'
44 }, { transaction: utils.transaction })
45 }
46}
47
48async function down (utils: {
49 queryInterface: Sequelize.QueryInterface
50 transaction: Sequelize.Transaction
51}) {
52 await utils.queryInterface.dropTable('videoChannelSync', { transaction: utils.transaction })
53}
54
55export {
56 up,
57 down
58}
diff --git a/server/initializers/migrations/0755-unique-viewer-url.ts b/server/initializers/migrations/0755-unique-viewer-url.ts
new file mode 100644
index 000000000..b3dff9258
--- /dev/null
+++ b/server/initializers/migrations/0755-unique-viewer-url.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 const { transaction } = utils
10
11 const query = 'DELETE FROM "localVideoViewer" t1 ' +
12 'USING (SELECT MIN(id) as id, "url" FROM "localVideoViewer" GROUP BY "url" HAVING COUNT(*) > 1) t2 ' +
13 'WHERE t1."url" = t2."url" AND t1.id <> t2.id'
14
15 await utils.sequelize.query(query, { transaction })
16}
17
18async function down (utils: {
19 queryInterface: Sequelize.QueryInterface
20 transaction: Sequelize.Transaction
21}) {
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
index 053112801..bc5b74257 100644
--- a/server/lib/auth/external-auth.ts
+++ b/server/lib/auth/external-auth.ts
@@ -1,26 +1,35 @@
1 1
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' 2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger' 10import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils' 11import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' 12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager' 13import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
8import { 16import {
9 RegisterServerAuthenticatedResult, 17 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions, 18 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult 19 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model' 20} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models' 21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
14 27
15// Token is the key, expiration date is the value 28// Token is the key, expiration date is the value
16const authBypassTokens = new Map<string, { 29const authBypassTokens = new Map<string, {
17 expires: Date 30 expires: Date
18 user: { 31 user: ExternalUser
19 username: string 32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
20 email: string
21 displayName: string
22 role: UserRole
23 }
24 authName: string 33 authName: string
25 npmName: string 34 npmName: string
26}>() 35}>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
56 expires, 65 expires,
57 user, 66 user,
58 npmName, 67 npmName,
59 authName 68 authName,
69 userUpdater: authResult.userUpdater
60 }) 70 })
61 71
62 // Cleanup expired tokens 72 // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
78 return tokenModel?.authName 88 return tokenModel?.authName
79} 89}
80 90
81async function getBypassFromPasswordGrant (username: string, password: string) { 91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
82 const plugins = PluginManager.Instance.getIdAndPassAuths() 92 const plugins = PluginManager.Instance.getIdAndPassAuths()
83 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] 93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
84 94
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
133 bypass: true, 143 bypass: true,
134 pluginName: pluginAuth.npmName, 144 pluginName: pluginAuth.npmName,
135 authName: authOptions.authName, 145 authName: authOptions.authName,
136 user: buildUserResult(loginResult) 146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
137 } 148 }
138 } catch (err) { 149 } catch (err) {
139 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) 150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
143 return undefined 154 return undefined
144} 155}
145 156
146function getBypassFromExternalAuth (username: string, externalAuthToken: string) { 157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
147 const obj = authBypassTokens.get(externalAuthToken) 158 const obj = authBypassTokens.get(externalAuthToken)
148 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') 159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
149 160
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
167 bypass: true, 178 bypass: true,
168 pluginName: npmName, 179 pluginName: npmName,
169 authName, 180 authName,
181 userUpdater: obj.userUpdater,
170 user 182 user
171 } 183 }
172} 184}
173 185
174function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { 186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
175 if (!isUserUsernameValid(result.username)) { 187 const returnError = (field: string) => {
176 logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) 188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
177 return false 189 return false
178 } 190 }
179 191
180 if (!result.email) { 192 if (!isUserUsernameValid(result.username)) return returnError('username')
181 logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) 193 if (!result.email) return returnError('email')
182 return false
183 }
184 194
185 // role is optional 195 // Following fields are optional
186 if (result.role && !isUserRoleValid(result.role)) { 196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
187 logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) 197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
188 return false 198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
189 } 199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
190 201
191 // display name is optional 202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
192 if (result.displayName && !isUserDisplayNameValid(result.displayName)) { 203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
193 logger.error(
194 'Auth method %s of plugin %s did not provide a valid display name.',
195 authName, npmName, { displayName: result.displayName }
196 )
197 return false 204 return false
198 } 205 }
199 206
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
205 username: pluginResult.username, 212 username: pluginResult.username,
206 email: pluginResult.email, 213 email: pluginResult.email,
207 role: pluginResult.role ?? UserRole.USER, 214 role: pluginResult.role ?? UserRole.USER,
208 displayName: pluginResult.displayName || pluginResult.username 215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
209 } 221 }
210} 222}
211 223
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 322b69e3a..43909284f 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,11 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server' 2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
4import { MOAuthClient } from '@server/types/models' 6import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user' 8import { MUser, MUserDefault } from '@server/types/models/user/user'
7import { pick } from '@shared/core-utils' 9import { pick } from '@shared/core-utils'
8import { UserRole } from '@shared/models/users/user-role' 10import { AttributesOnly } from '@shared/typescript-utils'
9import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 12import { CONFIG } from '../../initializers/config'
11import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
13import { UserModel } from '../../models/user/user' 15import { UserModel } from '../../models/user/user'
14import { findAvailableLocalActorName } from '../local-actor' 16import { findAvailableLocalActorName } from '../local-actor'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' 17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
16import { TokensCache } from './tokens-cache' 19import { TokensCache } from './tokens-cache'
17 20
18type TokenInfo = { 21type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
26 bypass: boolean 29 bypass: boolean
27 pluginName: string 30 pluginName: string
28 authName?: string 31 authName?: string
29 user: { 32 user: ExternalUser
30 username: string 33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
31 email: string
32 displayName: string
33 role: UserRole
34 }
35} 34}
36 35
37async function getAccessToken (bearerToken: string) { 36async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) 88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90 89
91 let user = await UserModel.loadByEmail(bypassLogin.user.email) 90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) 92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
93 94
94 // Cannot create a user 95 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') 96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
219 220
220// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
221 222
222async function createUserFromExternal (pluginAuth: string, options: { 223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
223 username: string 224 const username = await findAvailableLocalActorName(userOptions.username)
224 email: string
225 role: UserRole
226 displayName: string
227}) {
228 const username = await findAvailableLocalActorName(options.username)
229 225
230 const userToCreate = buildUser({ 226 const userToCreate = buildUser({
231 ...pick(options, [ 'email', 'role' ]), 227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
232 228
233 username, 229 username,
234 emailVerified: null, 230 emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
238 234
239 const { user } = await createUserAccountAndChannelAndPlaylist({ 235 const { user } = await createUserAccountAndChannelAndPlaylist({
240 userToCreate, 236 userToCreate,
241 userDisplayName: options.displayName 237 userDisplayName: userOptions.displayName
242 }) 238 })
243 239
244 return user 240 return user
245} 241}
246 242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
247function checkUserValidityOrThrow (user: MUser) { 288function checkUserValidityOrThrow (user: MUser) {
248 if (user.blocked) throw new AccessDeniedError('User is blocked.') 289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
249} 290}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index bc0d4301f..887c4f7c9 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -10,20 +10,32 @@ import OAuth2Server, {
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
14import { UserRegistrationModel } from '@server/models/user/user-registration'
13import { MOAuthClient } from '@server/types/models' 15import { MOAuthClient } from '@server/types/models'
14import { sha1 } from '@shared/extra-utils' 16import { sha1 } from '@shared/extra-utils'
15import { HttpStatusCode } from '@shared/models' 17import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' 18import { OTP } from '../../initializers/constants'
17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 19import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
18 20
19class MissingTwoFactorError extends Error { 21class MissingTwoFactorError extends Error {
20 code = HttpStatusCode.UNAUTHORIZED_401 22 code = HttpStatusCode.UNAUTHORIZED_401
21 name = 'missing_two_factor' 23 name = ServerErrorCode.MISSING_TWO_FACTOR
22} 24}
23 25
24class InvalidTwoFactorError extends Error { 26class InvalidTwoFactorError extends Error {
25 code = HttpStatusCode.BAD_REQUEST_400 27 code = HttpStatusCode.BAD_REQUEST_400
26 name = 'invalid_two_factor' 28 name = ServerErrorCode.INVALID_TWO_FACTOR
29}
30
31class RegistrationWaitingForApproval extends Error {
32 code = HttpStatusCode.BAD_REQUEST_400
33 name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
34}
35
36class RegistrationApprovalRejected extends Error {
37 code = HttpStatusCode.BAD_REQUEST_400
38 name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
27} 39}
28 40
29/** 41/**
@@ -32,8 +44,9 @@ class InvalidTwoFactorError extends Error {
32 * 44 *
33 */ 45 */
34const oAuthServer = new OAuth2Server({ 46const oAuthServer = new OAuth2Server({
35 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, 47 // Wants seconds
36 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, 48 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
49 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
37 50
38 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications 51 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
39 model: require('./oauth-model') 52 model: require('./oauth-model')
@@ -126,7 +139,17 @@ async function handlePasswordGrant (options: {
126 } 139 }
127 140
128 const user = await getUser(request.body.username, request.body.password, bypassLogin) 141 const user = await getUser(request.body.username, request.body.password, bypassLogin)
129 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 }
130 153
131 if (user.otpSecret) { 154 if (user.otpSecret) {
132 if (!request.headers[OTP.HEADER_NAME]) { 155 if (!request.headers[OTP.HEADER_NAME]) {
@@ -182,10 +205,10 @@ function generateRandomToken () {
182 205
183function getTokenExpiresAt (type: 'access' | 'refresh') { 206function getTokenExpiresAt (type: 'access' | 'refresh') {
184 const lifetime = type === 'access' 207 const lifetime = type === 'access'
185 ? OAUTH_LIFETIME.ACCESS_TOKEN 208 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
186 : OAUTH_LIFETIME.REFRESH_TOKEN 209 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
187 210
188 return new Date(Date.now() + lifetime * 1000) 211 return new Date(Date.now() + lifetime)
189} 212}
190 213
191async function buildToken () { 214async function buildToken () {
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
index 410708a35..43efc7d02 100644
--- a/server/lib/auth/tokens-cache.ts
+++ b/server/lib/auth/tokens-cache.ts
@@ -36,8 +36,8 @@ export class TokensCache {
36 const token = this.userHavingToken.get(userId) 36 const token = this.userHavingToken.get(userId)
37 37
38 if (token !== undefined) { 38 if (token !== undefined) {
39 this.accessTokenCache.del(token) 39 this.accessTokenCache.delete(token)
40 this.userHavingToken.del(userId) 40 this.userHavingToken.delete(userId)
41 } 41 }
42 } 42 }
43 43
@@ -45,8 +45,8 @@ export class TokensCache {
45 const tokenModel = this.accessTokenCache.get(token) 45 const tokenModel = this.accessTokenCache.get(token)
46 46
47 if (tokenModel !== undefined) { 47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId) 48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.del(token) 49 this.accessTokenCache.delete(token)
50 } 50 }
51 } 51 }
52} 52}
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 39b662eb2..f5c3e4745 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -3,13 +3,13 @@ import { merge } from 'lodash'
3import { createTransport, Transporter } from 'nodemailer' 3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path' 4import { join } from 'path'
5import { arrayify, root } from '@shared/core-utils' 5import { arrayify, root } from '@shared/core-utils'
6import { EmailPayload } from '@shared/models' 6import { EmailPayload, UserRegistrationState } from '@shared/models'
7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' 7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
8import { isTestOrDevInstance } from '../helpers/core-utils' 8import { isTestOrDevInstance } from '../helpers/core-utils'
9import { bunyanLogger, logger } from '../helpers/logger' 9import { bunyanLogger, logger } from '../helpers/logger'
10import { CONFIG, isEmailEnabled } from '../initializers/config' 10import { CONFIG, isEmailEnabled } from '../initializers/config'
11import { WEBSERVER } from '../initializers/constants' 11import { WEBSERVER } from '../initializers/constants'
12import { MUser } from '../types/models' 12import { MRegistration, MUser } from '../types/models'
13import { JobQueue } from './job-queue' 13import { JobQueue } from './job-queue'
14 14
15const Email = require('email-templates') 15const Email = require('email-templates')
@@ -62,7 +62,9 @@ class Emailer {
62 subject: 'Reset your account password', 62 subject: 'Reset your account password',
63 locals: { 63 locals: {
64 username, 64 username,
65 resetPasswordUrl 65 resetPasswordUrl,
66
67 hideNotificationPreferencesLink: true
66 } 68 }
67 } 69 }
68 70
@@ -76,21 +78,33 @@ class Emailer {
76 subject: 'Create your account password', 78 subject: 'Create your account password',
77 locals: { 79 locals: {
78 username, 80 username,
79 createPasswordUrl 81 createPasswordUrl,
82
83 hideNotificationPreferencesLink: true
80 } 84 }
81 } 85 }
82 86
83 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload }) 87 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
84 } 88 }
85 89
86 addVerifyEmailJob (username: string, to: string, verifyEmailUrl: string) { 90 addVerifyEmailJob (options: {
91 username: string
92 isRegistrationRequest: boolean
93 to: string
94 verifyEmailUrl: string
95 }) {
96 const { username, isRegistrationRequest, to, verifyEmailUrl } = options
97
87 const emailPayload: EmailPayload = { 98 const emailPayload: EmailPayload = {
88 template: 'verify-email', 99 template: 'verify-email',
89 to: [ to ], 100 to: [ to ],
90 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`, 101 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
91 locals: { 102 locals: {
92 username, 103 username,
93 verifyEmailUrl 104 verifyEmailUrl,
105 isRegistrationRequest,
106
107 hideNotificationPreferencesLink: true
94 } 108 }
95 } 109 }
96 110
@@ -123,7 +137,33 @@ class Emailer {
123 body, 137 body,
124 138
125 // There are not notification preferences for the contact form 139 // There are not notification preferences for the contact form
126 hideNotificationPreferences: true 140 hideNotificationPreferencesLink: true
141 }
142 }
143
144 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
145 }
146
147 addUserRegistrationRequestProcessedJob (registration: MRegistration) {
148 let template: string
149 let subject: string
150 if (registration.state === UserRegistrationState.ACCEPTED) {
151 template = 'user-registration-request-accepted'
152 subject = `Your registration request for ${registration.username} has been accepted`
153 } else {
154 template = 'user-registration-request-rejected'
155 subject = `Your registration request for ${registration.username} has been rejected`
156 }
157
158 const to = registration.email
159 const emailPayload: EmailPayload = {
160 to: [ to ],
161 template,
162 subject,
163 locals: {
164 username: registration.username,
165 moderationResponse: registration.moderationResponse,
166 loginLink: WEBSERVER.URL + '/login'
127 } 167 }
128 } 168 }
129 169
diff --git a/server/lib/emails/common/base.pug b/server/lib/emails/common/base.pug
index 6da5648e4..41e94564d 100644
--- a/server/lib/emails/common/base.pug
+++ b/server/lib/emails/common/base.pug
@@ -222,19 +222,9 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;') 222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
223 br 223 br
224 //- Clear Spacer : END 224 //- Clear Spacer : END
225 //- 1 Column Text : BEGIN
226 if username
227 tr
228 td(style='background-color: #cccccc;')
229 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
230 tr
231 td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
232 p(style='margin: 0;')
233 | You are receiving this email as part of your notification settings on #{instanceName} for your account #{username}.
234 //- 1 Column Text : END
235 //- Email Body : END 225 //- Email Body : END
236 //- Email Footer : BEGIN 226 //- Email Footer : BEGIN
237 unless hideNotificationPreferences 227 unless hideNotificationPreferencesLink
238 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') 228 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
239 tr 229 tr
240 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') 230 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
diff --git a/server/lib/emails/user-registration-request-accepted/html.pug b/server/lib/emails/user-registration-request-accepted/html.pug
new file mode 100644
index 000000000..7a52c3fe1
--- /dev/null
+++ b/server/lib/emails/user-registration-request-accepted/html.pug
@@ -0,0 +1,10 @@
1extends ../common/greetings
2
3block title
4 | Congratulation #{username}, your registration request has been accepted!
5
6block content
7 p Your registration request has been accepted.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
10 p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}]
diff --git a/server/lib/emails/user-registration-request-rejected/html.pug b/server/lib/emails/user-registration-request-rejected/html.pug
new file mode 100644
index 000000000..ec0aa8dfe
--- /dev/null
+++ b/server/lib/emails/user-registration-request-rejected/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | Registration request of your account #{username} has rejected
5
6block content
7 p Your registration request has been rejected.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
diff --git a/server/lib/emails/user-registration-request/html.pug b/server/lib/emails/user-registration-request/html.pug
new file mode 100644
index 000000000..64898f3f2
--- /dev/null
+++ b/server/lib/emails/user-registration-request/html.pug
@@ -0,0 +1,9 @@
1extends ../common/greetings
2
3block title
4 | A new user wants to register
5
6block content
7 p User #{registration.username} wants to register on your PeerTube instance with the following reason:
8 blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
9 p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
diff --git a/server/lib/emails/verify-email/html.pug b/server/lib/emails/verify-email/html.pug
index be9dde21b..19ef65f75 100644
--- a/server/lib/emails/verify-email/html.pug
+++ b/server/lib/emails/verify-email/html.pug
@@ -1,17 +1,19 @@
1extends ../common/greetings 1extends ../common/greetings
2 2
3block title 3block title
4 | Account verification 4 | Email verification
5 5
6block content 6block content
7 p Welcome to #{instanceName}! 7 if isRegistrationRequest
8 p. 8 p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
9 You just created an account at #[a(href=WEBSERVER.URL) #{instanceName}]. 9 else
10 Your username there is: #{username}. 10 p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
11 p. 11
12 To start using your account you must verify your email first! 12 if isRegistrationRequest
13 Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you. 13 p To complete your registration request you must verify your email first!
14 p. 14 else
15 If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}] 15 p To start using your account you must verify your email first!
16 p. 16
17 If you are not the person who initiated this request, please ignore this email. 17 p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
18 p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
19 p If you are not the person who initiated this request, please ignore this email.
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 866aa1ed0..8597eb000 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -184,7 +184,7 @@ class JobQueue {
184 184
185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST 185 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
186 186
187 for (const handlerName of (Object.keys(handlers) as JobType[])) { 187 for (const handlerName of Object.keys(handlers)) {
188 this.buildWorker(handlerName) 188 this.buildWorker(handlerName)
189 this.buildQueue(handlerName) 189 this.buildQueue(handlerName)
190 this.buildQueueScheduler(handlerName) 190 this.buildQueueScheduler(handlerName)
diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts
index 66cfc31c4..920c55df0 100644
--- a/server/lib/notifier/notifier.ts
+++ b/server/lib/notifier/notifier.ts
@@ -1,4 +1,4 @@
1import { MUser, MUserDefault } from '@server/types/models/user' 1import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist' 2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
3import { UserNotificationSettingValue } from '../../../shared/models/users' 3import { UserNotificationSettingValue } from '../../../shared/models/users'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
@@ -13,6 +13,7 @@ import {
13 AbuseStateChangeForReporter, 13 AbuseStateChangeForReporter,
14 AutoFollowForInstance, 14 AutoFollowForInstance,
15 CommentMention, 15 CommentMention,
16 DirectRegistrationForModerators,
16 FollowForInstance, 17 FollowForInstance,
17 FollowForUser, 18 FollowForUser,
18 ImportFinishedForOwner, 19 ImportFinishedForOwner,
@@ -30,7 +31,7 @@ import {
30 OwnedPublicationAfterAutoUnblacklist, 31 OwnedPublicationAfterAutoUnblacklist,
31 OwnedPublicationAfterScheduleUpdate, 32 OwnedPublicationAfterScheduleUpdate,
32 OwnedPublicationAfterTranscoding, 33 OwnedPublicationAfterTranscoding,
33 RegistrationForModerators, 34 RegistrationRequestForModerators,
34 StudioEditionFinishedForOwner, 35 StudioEditionFinishedForOwner,
35 UnblacklistForOwner 36 UnblacklistForOwner
36} from './shared' 37} from './shared'
@@ -47,7 +48,8 @@ class Notifier {
47 newBlacklist: [ NewBlacklistForOwner ], 48 newBlacklist: [ NewBlacklistForOwner ],
48 unblacklist: [ UnblacklistForOwner ], 49 unblacklist: [ UnblacklistForOwner ],
49 importFinished: [ ImportFinishedForOwner ], 50 importFinished: [ ImportFinishedForOwner ],
50 userRegistration: [ RegistrationForModerators ], 51 directRegistration: [ DirectRegistrationForModerators ],
52 registrationRequest: [ RegistrationRequestForModerators ],
51 userFollow: [ FollowForUser ], 53 userFollow: [ FollowForUser ],
52 instanceFollow: [ FollowForInstance ], 54 instanceFollow: [ FollowForInstance ],
53 autoInstanceFollow: [ AutoFollowForInstance ], 55 autoInstanceFollow: [ AutoFollowForInstance ],
@@ -138,13 +140,20 @@ class Notifier {
138 }) 140 })
139 } 141 }
140 142
141 notifyOnNewUserRegistration (user: MUserDefault): void { 143 notifyOnNewDirectRegistration (user: MUserDefault): void {
142 const models = this.notificationModels.userRegistration 144 const models = this.notificationModels.directRegistration
143 145
144 this.sendNotifications(models, user) 146 this.sendNotifications(models, user)
145 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err })) 147 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
146 } 148 }
147 149
150 notifyOnNewRegistrationRequest (registration: MRegistration): void {
151 const models = this.notificationModels.registrationRequest
152
153 this.sendNotifications(models, registration)
154 .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
155 }
156
148 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void { 157 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
149 const models = this.notificationModels.userFollow 158 const models = this.notificationModels.userFollow
150 159
diff --git a/server/lib/notifier/shared/instance/registration-for-moderators.ts b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
index e92467424..5044f2068 100644
--- a/server/lib/notifier/shared/instance/registration-for-moderators.ts
+++ b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
@@ -6,7 +6,7 @@ import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi
6import { UserNotificationType, UserRight } from '@shared/models' 6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification' 7import { AbstractNotification } from '../common/abstract-notification'
8 8
9export class RegistrationForModerators extends AbstractNotification <MUserDefault> { 9export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
10 private moderators: MUserDefault[] 10 private moderators: MUserDefault[]
11 11
12 async prepare () { 12 async prepare () {
@@ -40,7 +40,7 @@ export class RegistrationForModerators extends AbstractNotification <MUserDefaul
40 return { 40 return {
41 template: 'user-registered', 41 template: 'user-registered',
42 to, 42 to,
43 subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`, 43 subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
44 locals: { 44 locals: {
45 user: this.payload 45 user: this.payload
46 } 46 }
diff --git a/server/lib/notifier/shared/instance/index.ts b/server/lib/notifier/shared/instance/index.ts
index c3bb22aec..8c75a8ee9 100644
--- a/server/lib/notifier/shared/instance/index.ts
+++ b/server/lib/notifier/shared/instance/index.ts
@@ -1,3 +1,4 @@
1export * from './new-peertube-version-for-admins' 1export * from './new-peertube-version-for-admins'
2export * from './new-plugin-version-for-admins' 2export * from './new-plugin-version-for-admins'
3export * from './registration-for-moderators' 3export * from './direct-registration-for-moderators'
4export * from './registration-request-for-moderators'
diff --git a/server/lib/notifier/shared/instance/registration-request-for-moderators.ts b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
new file mode 100644
index 000000000..79920245a
--- /dev/null
+++ b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
@@ -0,0 +1,48 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
13 }
14
15 log () {
16 logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.newUserRegistration
21 }
22
23 getTargetUsers () {
24 return this.moderators
25 }
26
27 createNotification (user: MUserWithNotificationSetting) {
28 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
29 type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
30 userId: user.id,
31 userRegistrationId: this.payload.id
32 })
33 notification.UserRegistration = this.payload
34
35 return notification
36 }
37
38 createEmail (to: string) {
39 return {
40 template: 'user-registration-request',
41 to,
42 subject: `A new user wants to register: ${this.payload.username}`,
43 locals: {
44 registration: this.payload
45 }
46 }
47 }
48}
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
new file mode 100644
index 000000000..ef40c0fa9
--- /dev/null
+++ b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
@@ -0,0 +1,51 @@
1import { Meter } from '@opentelemetry/api'
2
3export class BittorrentTrackerObserversBuilder {
4
5 constructor (private readonly meter: Meter, private readonly trackerServer: any) {
6
7 }
8
9 buildObservers () {
10 const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
11 description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
12 })
13 const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
14 description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
15 })
16 const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
17 description: 'Total peers in the PeerTube BitTorrent Tracker'
18 })
19
20 this.meter.addBatchObservableCallback(observableResult => {
21 const infohashes = Object.keys(this.trackerServer.torrents)
22
23 const counters = {
24 activeInfohashes: 0,
25 inactiveInfohashes: 0,
26 peers: 0,
27 uncompletedPeers: 0
28 }
29
30 for (const infohash of infohashes) {
31 const content = this.trackerServer.torrents[infohash]
32
33 const peers = content.peers
34 if (peers.keys.length !== 0) counters.activeInfohashes++
35 else counters.inactiveInfohashes++
36
37 for (const peerId of peers.keys) {
38 const peer = peers.peek(peerId)
39 if (peer == null) return
40
41 counters.peers++
42 }
43 }
44
45 observableResult.observe(activeInfohashes, counters.activeInfohashes)
46 observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
47 observableResult.observe(peers, counters.peers)
48 }, [ activeInfohashes, inactiveInfohashes, peers ])
49 }
50
51}
diff --git a/server/lib/opentelemetry/metric-helpers/index.ts b/server/lib/opentelemetry/metric-helpers/index.ts
index 775d954ba..47b24a54f 100644
--- a/server/lib/opentelemetry/metric-helpers/index.ts
+++ b/server/lib/opentelemetry/metric-helpers/index.ts
@@ -1,3 +1,4 @@
1export * from './bittorrent-tracker-observers-builder'
1export * from './lives-observers-builder' 2export * from './lives-observers-builder'
2export * from './job-queue-observers-builder' 3export * from './job-queue-observers-builder'
3export * from './nodejs-observers-builder' 4export * from './nodejs-observers-builder'
diff --git a/server/lib/opentelemetry/metrics.ts b/server/lib/opentelemetry/metrics.ts
index 226d514c0..9cc067e4a 100644
--- a/server/lib/opentelemetry/metrics.ts
+++ b/server/lib/opentelemetry/metrics.ts
@@ -7,6 +7,7 @@ import { CONFIG } from '@server/initializers/config'
7import { MVideoImmutable } from '@server/types/models' 7import { MVideoImmutable } from '@server/types/models'
8import { PlaybackMetricCreate } from '@shared/models' 8import { PlaybackMetricCreate } from '@shared/models'
9import { 9import {
10 BittorrentTrackerObserversBuilder,
10 JobQueueObserversBuilder, 11 JobQueueObserversBuilder,
11 LivesObserversBuilder, 12 LivesObserversBuilder,
12 NodeJSObserversBuilder, 13 NodeJSObserversBuilder,
@@ -41,7 +42,7 @@ class OpenTelemetryMetrics {
41 }) 42 })
42 } 43 }
43 44
44 registerMetrics () { 45 registerMetrics (options: { trackerServer: any }) {
45 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return 46 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
46 47
47 logger.info('Registering Open Telemetry metrics') 48 logger.info('Registering Open Telemetry metrics')
@@ -80,6 +81,9 @@ class OpenTelemetryMetrics {
80 81
81 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter) 82 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
82 viewersObserversBuilder.buildObservers() 83 viewersObserversBuilder.buildObservers()
84
85 const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
86 bittorrentTrackerObserversBuilder.buildObservers()
83 } 87 }
84 88
85 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) { 89 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 7b1def6e3..66383af46 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -209,6 +209,10 @@ function buildConfigHelpers () {
209 return WEBSERVER.URL 209 return WEBSERVER.URL
210 }, 210 },
211 211
212 getServerListeningConfig () {
213 return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
214 },
215
212 getServerConfig () { 216 getServerConfig () {
213 return ServerConfigManager.Instance.getServerConfig() 217 return ServerConfigManager.Instance.getServerConfig()
214 } 218 }
@@ -245,7 +249,7 @@ function buildUserHelpers () {
245 }, 249 },
246 250
247 getAuthUser: (res: express.Response) => { 251 getAuthUser: (res: express.Response) => {
248 const user = res.locals.oauth?.token?.User 252 const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
249 if (!user) return undefined 253 if (!user) return undefined
250 254
251 return UserModel.loadByIdFull(user.id) 255 return UserModel.loadByIdFull(user.id)
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index c0e9aece7..3706d2228 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -8,9 +8,8 @@ import {
8 AP_CLEANER, 8 AP_CLEANER,
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, 11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
13 USER_EMAIL_VERIFY_LIFETIME, 12 EMAIL_VERIFY_LIFETIME,
14 USER_PASSWORD_CREATE_LIFETIME, 13 USER_PASSWORD_CREATE_LIFETIME,
15 USER_PASSWORD_RESET_LIFETIME, 14 USER_PASSWORD_RESET_LIFETIME,
16 VIEW_LIFETIME, 15 VIEW_LIFETIME,
@@ -125,16 +124,28 @@ class Redis {
125 124
126 /* ************ Email verification ************ */ 125 /* ************ Email verification ************ */
127 126
128 async setVerifyEmailVerificationString (userId: number) { 127 async setUserVerifyEmailVerificationString (userId: number) {
129 const generatedString = await generateRandomString(32) 128 const generatedString = await generateRandomString(32)
130 129
131 await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) 130 await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
132 131
133 return generatedString 132 return generatedString
134 } 133 }
135 134
136 async getVerifyEmailLink (userId: number) { 135 async getUserVerifyEmailLink (userId: number) {
137 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))
138 } 149 }
139 150
140 /* ************ Contact form per IP ************ */ 151 /* ************ Contact form per IP ************ */
@@ -157,16 +168,6 @@ class Redis {
157 return this.exists(this.generateIPViewKey(ip, videoUUID)) 168 return this.exists(this.generateIPViewKey(ip, videoUUID))
158 } 169 }
159 170
160 /* ************ Tracker IP block ************ */
161
162 setTrackerBlockIP (ip: string) {
163 return this.setValue(this.generateTrackerBlockIPKey(ip), '1', TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME)
164 }
165
166 async doesTrackerBlockIPExist (ip: string) {
167 return this.exists(this.generateTrackerBlockIPKey(ip))
168 }
169
170 /* ************ Video views stats ************ */ 171 /* ************ Video views stats ************ */
171 172
172 addVideoViewStats (videoId: number) { 173 addVideoViewStats (videoId: number) {
@@ -357,16 +358,16 @@ class Redis {
357 return 'two-factor-request-' + userId + '-' + token 358 return 'two-factor-request-' + userId + '-' + token
358 } 359 }
359 360
360 private generateVerifyEmailKey (userId: number) { 361 private generateUserVerifyEmailKey (userId: number) {
361 return 'verify-email-' + userId 362 return 'verify-email-user-' + userId
362 } 363 }
363 364
364 private generateIPViewKey (ip: string, videoUUID: string) { 365 private generateRegistrationVerifyEmailKey (registrationId: number) {
365 return `views-${videoUUID}-${ip}` 366 return 'verify-email-registration-' + registrationId
366 } 367 }
367 368
368 private generateTrackerBlockIPKey (ip: string) { 369 private generateIPViewKey (ip: string, videoUUID: string) {
369 return `tracker-block-ip-${ip}` 370 return `views-${videoUUID}-${ip}`
370 } 371 }
371 372
372 private generateContactFormKey (ip: string) { 373 private generateContactFormKey (ip: string) {
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 78a9546ae..e87e2854f 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -261,10 +261,17 @@ class ServerConfigManager {
261 async getServerConfig (ip?: string): Promise<ServerConfig> { 261 async getServerConfig (ip?: string): Promise<ServerConfig> {
262 const { allowed } = await Hooks.wrapPromiseFun( 262 const { allowed } = await Hooks.wrapPromiseFun(
263 isSignupAllowed, 263 isSignupAllowed,
264
264 { 265 {
265 ip 266 ip,
267 signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
268 ? 'request-registration'
269 : 'direct-registration'
266 }, 270 },
267 'filter:api.user.signup.allowed.result' 271
272 CONFIG.SIGNUP.REQUIRES_APPROVAL
273 ? 'filter:api.user.request-signup.allowed.result'
274 : 'filter:api.user.signup.allowed.result'
268 ) 275 )
269 276
270 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) 277 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
@@ -273,6 +280,7 @@ class ServerConfigManager {
273 allowed, 280 allowed,
274 allowedForCurrentIP, 281 allowedForCurrentIP,
275 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE, 282 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
283 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
276 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 284 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
277 } 285 }
278 286
diff --git a/server/lib/signup.ts b/server/lib/signup.ts
index f094531eb..f19232621 100644
--- a/server/lib/signup.ts
+++ b/server/lib/signup.ts
@@ -4,11 +4,24 @@ import { UserModel } from '../models/user/user'
4 4
5const isCidr = require('is-cidr') 5const isCidr = require('is-cidr')
6 6
7async function isSignupAllowed (): Promise<{ allowed: boolean, errorMessage?: string }> { 7export type SignupMode = 'direct-registration' | 'request-registration'
8
9async function isSignupAllowed (options: {
10 signupMode: SignupMode
11
12 ip: string // For plugins
13 body?: any
14}): Promise<{ allowed: boolean, errorMessage?: string }> {
15 const { signupMode } = options
16
8 if (CONFIG.SIGNUP.ENABLED === false) { 17 if (CONFIG.SIGNUP.ENABLED === false) {
9 return { allowed: false } 18 return { allowed: false }
10 } 19 }
11 20
21 if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
22 return { allowed: false }
23 }
24
12 // No limit and signup is enabled 25 // No limit and signup is enabled
13 if (CONFIG.SIGNUP.LIMIT === -1) { 26 if (CONFIG.SIGNUP.LIMIT === -1) {
14 return { allowed: true } 27 return { allowed: true }
diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts
index 10167ee38..3a805a943 100644
--- a/server/lib/sync-channel.ts
+++ b/server/lib/sync-channel.ts
@@ -76,7 +76,7 @@ export async function synchronizeChannel (options: {
76 76
77 await JobQueue.Instance.createJobWithChildren(parent, children) 77 await JobQueue.Instance.createJobWithChildren(parent, children)
78 } catch (err) { 78 } catch (err) {
79 logger.error(`Failed to import channel ${channel.name}`, { err }) 79 logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
80 channelSync.state = VideoChannelSyncState.FAILED 80 channelSync.state = VideoChannelSyncState.FAILED
81 await channelSync.save() 81 await channelSync.save()
82 } 82 }
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 2e433da04..ffb57944a 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -10,7 +10,7 @@ import { sequelizeTypescript } from '../initializers/database'
10import { AccountModel } from '../models/account/account' 10import { AccountModel } from '../models/account/account'
11import { UserNotificationSettingModel } from '../models/user/user-notification-setting' 11import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
12import { MAccountDefault, MChannelActor } from '../types/models' 12import { MAccountDefault, MChannelActor } from '../types/models'
13import { MUser, MUserDefault, MUserId } from '../types/models/user' 13import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
14import { generateAndSaveActorKeys } from './activitypub/actors' 14import { generateAndSaveActorKeys } from './activitypub/actors'
15import { getLocalAccountActivityPubUrl } from './activitypub/url' 15import { getLocalAccountActivityPubUrl } from './activitypub/url'
16import { Emailer } from './emailer' 16import { Emailer } from './emailer'
@@ -97,7 +97,7 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
97 }) 97 })
98 userCreated.Account = accountCreated 98 userCreated.Account = accountCreated
99 99
100 const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames) 100 const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t) 101 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
102 102
103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) 103 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
@@ -160,15 +160,28 @@ async function createApplicationActor (applicationId: number) {
160// --------------------------------------------------------------------------- 160// ---------------------------------------------------------------------------
161 161
162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) { 162async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
163 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id) 163 const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
164 let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString 164 let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
165 165
166 if (isPendingEmail) url += '&isPendingEmail=true' 166 if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
167
168 const to = isPendingEmail
169 ? user.pendingEmail
170 : user.email
167 171
168 const email = isPendingEmail ? user.pendingEmail : user.email
169 const username = user.username 172 const username = user.username
170 173
171 Emailer.Instance.addVerifyEmailJob(username, email, url) 174 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
175}
176
177async function sendVerifyRegistrationEmail (registration: MRegistration) {
178 const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
179 const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
180
181 const to = registration.email
182 const username = registration.username
183
184 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
172} 185}
173 186
174// --------------------------------------------------------------------------- 187// ---------------------------------------------------------------------------
@@ -232,7 +245,10 @@ export {
232 createApplicationActor, 245 createApplicationActor,
233 createUserAccountAndChannelAndPlaylist, 246 createUserAccountAndChannelAndPlaylist,
234 createLocalAccountWithoutKeys, 247 createLocalAccountWithoutKeys,
248
235 sendVerifyUserEmail, 249 sendVerifyUserEmail,
250 sendVerifyRegistrationEmail,
251
236 isAbleToUploadVideo, 252 isAbleToUploadVideo,
237 buildUser 253 buildUser
238} 254}
@@ -264,7 +280,13 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
264 return UserNotificationSettingModel.create(values, { transaction: t }) 280 return UserNotificationSettingModel.create(values, { transaction: t })
265} 281}
266 282
267async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) { 283async function buildChannelAttributes (options: {
284 user: MUser
285 transaction?: Transaction
286 channelNames?: ChannelNames
287}) {
288 const { user, transaction, channelNames } = options
289
268 if (channelNames) return channelNames 290 if (channelNames) return channelNames
269 291
270 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction) 292 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 02f160fe8..6eb865f7f 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,30 +1,41 @@
1import express from 'express'
1import { cloneDeep } from 'lodash' 2import { cloneDeep } from 'lodash'
2import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
3import express from 'express'
4import { logger } from '@server/helpers/logger' 4import { logger } from '@server/helpers/logger'
5import { sequelizeTypescript } from '@server/initializers/database' 5import { sequelizeTypescript } from '@server/initializers/database'
6import { ResultList } from '../../shared/models' 6import { ResultList } from '../../shared/models'
7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model' 7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
8import { VideoCommentModel } from '../models/video/video-comment' 8import { VideoCommentModel } from '../models/video/video-comment'
9import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' 9import {
10 MAccountDefault,
11 MComment,
12 MCommentFormattable,
13 MCommentOwnerVideo,
14 MCommentOwnerVideoReply,
15 MVideoFullLight
16} from '../types/models'
10import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' 17import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
11import { getLocalVideoCommentActivityPubUrl } from './activitypub/url' 18import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
12import { Hooks } from './plugins/hooks' 19import { Hooks } from './plugins/hooks'
13 20
14async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) { 21async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
15 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance) 22 let videoCommentInstanceBefore: MCommentOwnerVideo
16 23
17 await sequelizeTypescript.transaction(async t => { 24 await sequelizeTypescript.transaction(async t => {
18 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) { 25 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
19 await sendDeleteVideoComment(videoCommentInstance, t) 26
27 videoCommentInstanceBefore = cloneDeep(comment)
28
29 if (comment.isOwned() || comment.Video.isOwned()) {
30 await sendDeleteVideoComment(comment, t)
20 } 31 }
21 32
22 videoCommentInstance.markAsDeleted() 33 comment.markAsDeleted()
23 34
24 await videoCommentInstance.save({ transaction: t }) 35 await comment.save({ transaction: t })
25 })
26 36
27 logger.info('Video comment %d deleted.', videoCommentInstance.id) 37 logger.info('Video comment %d deleted.', comment.id)
38 })
28 39
29 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res }) 40 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
30} 41}
@@ -64,7 +75,7 @@ async function createVideoComment (obj: {
64 return savedComment 75 return savedComment
65} 76}
66 77
67function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree { 78function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
68 // Comments are sorted by id ASC 79 // Comments are sorted by id ASC
69 const comments = resultList.data 80 const comments = resultList.data
70 81
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index c43085d16..17aa29cdd 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -1,5 +1,7 @@
1import LRUCache from 'lru-cache' 1import LRUCache from 'lru-cache'
2import { LRU_CACHE } from '@server/initializers/constants' 2import { LRU_CACHE } from '@server/initializers/constants'
3import { MUserAccountUrl } from '@server/types/models'
4import { pick } from '@shared/core-utils'
3import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
4 6
5// --------------------------------------------------------------------------- 7// ---------------------------------------------------------------------------
@@ -10,19 +12,22 @@ class VideoTokensManager {
10 12
11 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
12 14
13 private readonly lruCache = new LRUCache<string, string>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({
14 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
15 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
16 }) 18 })
17 19
18 private constructor () {} 20 private constructor () {}
19 21
20 create (videoUUID: string) { 22 create (options: {
23 user: MUserAccountUrl
24 videoUUID: string
25 }) {
21 const token = buildUUID() 26 const token = buildUUID()
22 27
23 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL) 28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
24 29
25 this.lruCache.set(token, videoUUID) 30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
26 31
27 return { token, expires } 32 return { token, expires }
28 } 33 }
@@ -34,7 +39,16 @@ class VideoTokensManager {
34 const value = this.lruCache.get(options.token) 39 const value = this.lruCache.get(options.token)
35 if (!value) return false 40 if (!value) return false
36 41
37 return value === options.videoUUID 42 return value.videoUUID === options.videoUUID
43 }
44
45 getUserFromToken (options: {
46 token: string
47 }) {
48 const value = this.lruCache.get(options.token)
49 if (!value) return undefined
50
51 return value.user
38 } 52 }
39 53
40 static get Instance () { 54 static get Instance () {
diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts
index 458895898..77a532276 100644
--- a/server/middlewares/sort.ts
+++ b/server/middlewares/sort.ts
@@ -1,5 +1,4 @@
1import express from 'express' 1import express from 'express'
2import { SortType } from '../models/utils'
3 2
4const setDefaultSort = setDefaultSortFactory('-createdAt') 3const setDefaultSort = setDefaultSortFactory('-createdAt')
5const setDefaultVideosSort = setDefaultSortFactory('-publishedAt') 4const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
@@ -7,27 +6,7 @@ const setDefaultVideosSort = setDefaultSortFactory('-publishedAt')
7const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name') 6const setDefaultVideoRedundanciesSort = setDefaultSortFactory('name')
8 7
9const setDefaultSearchSort = setDefaultSortFactory('-match') 8const setDefaultSearchSort = setDefaultSortFactory('-match')
10 9const setBlacklistSort = setDefaultSortFactory('-createdAt')
11function setBlacklistSort (req: express.Request, res: express.Response, next: express.NextFunction) {
12 const newSort: SortType = { sortModel: undefined, sortValue: '' }
13
14 if (!req.query.sort) req.query.sort = '-createdAt'
15
16 // Set model we want to sort onto
17 if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' ||
18 req.query.sort === '-id' || req.query.sort === 'id') {
19 // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter...
20 newSort.sortModel = undefined
21 } else {
22 newSort.sortModel = 'Video'
23 }
24
25 newSort.sortValue = req.query.sort
26
27 req.query.sort = newSort
28
29 return next()
30}
31 10
32// --------------------------------------------------------------------------- 11// ---------------------------------------------------------------------------
33 12
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index 3a7daa573..c2dbfadb7 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -29,6 +29,7 @@ const customConfigUpdateValidator = [
29 body('signup.enabled').isBoolean(), 29 body('signup.enabled').isBoolean(),
30 body('signup.limit').isInt(), 30 body('signup.limit').isInt(),
31 body('signup.requiresEmailVerification').isBoolean(), 31 body('signup.requiresEmailVerification').isBoolean(),
32 body('signup.requiresApproval').isBoolean(),
32 body('signup.minimumAge').isInt(), 33 body('signup.minimumAge').isInt(),
33 34
34 body('admin.email').isEmail(), 35 body('admin.email').isEmail(),
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 9bc8887ff..1d0964667 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -21,8 +21,10 @@ export * from './server'
21export * from './sort' 21export * from './sort'
22export * from './static' 22export * from './static'
23export * from './themes' 23export * from './themes'
24export * from './user-email-verification'
24export * from './user-history' 25export * from './user-history'
25export * from './user-notifications' 26export * from './user-notifications'
27export * from './user-registrations'
26export * from './user-subscriptions' 28export * from './user-subscriptions'
27export * from './users' 29export * from './users'
28export * from './videos' 30export * from './videos'
diff --git a/server/middlewares/validators/shared/user-registrations.ts b/server/middlewares/validators/shared/user-registrations.ts
new file mode 100644
index 000000000..dbc7dda06
--- /dev/null
+++ b/server/middlewares/validators/shared/user-registrations.ts
@@ -0,0 +1,60 @@
1import express from 'express'
2import { UserRegistrationModel } from '@server/models/user/user-registration'
3import { MRegistration } from '@server/types/models'
4import { forceNumber, pick } from '@shared/core-utils'
5import { HttpStatusCode } from '@shared/models'
6
7function checkRegistrationIdExist (idArg: number | string, res: express.Response) {
8 const id = forceNumber(idArg)
9 return checkRegistrationExist(() => UserRegistrationModel.load(id), res)
10}
11
12function checkRegistrationEmailExist (email: string, res: express.Response, abortResponse = true) {
13 return checkRegistrationExist(() => UserRegistrationModel.loadByEmail(email), res, abortResponse)
14}
15
16async function checkRegistrationHandlesDoNotAlreadyExist (options: {
17 username: string
18 channelHandle: string
19 email: string
20 res: express.Response
21}) {
22 const { res } = options
23
24 const registration = await UserRegistrationModel.loadByEmailOrHandle(pick(options, [ 'username', 'email', 'channelHandle' ]))
25
26 if (registration) {
27 res.fail({
28 status: HttpStatusCode.CONFLICT_409,
29 message: 'Registration with this username, channel name or email already exists.'
30 })
31 return false
32 }
33
34 return true
35}
36
37async function checkRegistrationExist (finder: () => Promise<MRegistration>, res: express.Response, abortResponse = true) {
38 const registration = await finder()
39
40 if (!registration) {
41 if (abortResponse === true) {
42 res.fail({
43 status: HttpStatusCode.NOT_FOUND_404,
44 message: 'User not found'
45 })
46 }
47
48 return false
49 }
50
51 res.locals.userRegistration = registration
52 return true
53}
54
55export {
56 checkRegistrationIdExist,
57 checkRegistrationEmailExist,
58 checkRegistrationHandlesDoNotAlreadyExist,
59 checkRegistrationExist
60}
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
index b8f1436d3..030adc9f7 100644
--- a/server/middlewares/validators/shared/users.ts
+++ b/server/middlewares/validators/shared/users.ts
@@ -14,7 +14,7 @@ function checkUserEmailExist (email: string, res: express.Response, abortRespons
14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) 14 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
15} 15}
16 16
17async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { 17async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
18 const user = await UserModel.loadByUsernameOrEmail(username, email) 18 const user = await UserModel.loadByUsernameOrEmail(username, email)
19 19
20 if (user) { 20 if (user) {
@@ -58,6 +58,6 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
58export { 58export {
59 checkUserIdExist, 59 checkUserIdExist,
60 checkUserEmailExist, 60 checkUserEmailExist,
61 checkUserNameOrEmailDoesNotAlreadyExist, 61 checkUserNameOrEmailDoNotAlreadyExist,
62 checkUserExist 62 checkUserExist
63} 63}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index ebbfc0a0a..0033a32ff 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -180,18 +180,16 @@ async function checkCanAccessVideoStaticFiles (options: {
180 return checkCanSeeVideo(options) 180 return checkCanSeeVideo(options)
181 } 181 }
182 182
183 if (!video.hasPrivateStaticPath()) return true
184
185 const videoFileToken = req.query.videoFileToken 183 const videoFileToken = req.query.videoFileToken
186 if (!videoFileToken) { 184 if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
187 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 185 const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
188 return false
189 }
190 186
191 if (VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) { 187 res.locals.videoFileToken = { user }
192 return true 188 return true
193 } 189 }
194 190
191 if (!video.hasPrivateStaticPath()) return true
192
195 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 193 res.sendStatus(HttpStatusCode.FORBIDDEN_403)
196 return false 194 return false
197} 195}
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 7d0639107..e6cc46317 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -1,9 +1,41 @@
1import express from 'express' 1import express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3
4import { SORTABLE_COLUMNS } from '../../initializers/constants' 3import { SORTABLE_COLUMNS } from '../../initializers/constants'
5import { areValidationErrors } from './shared' 4import { areValidationErrors } from './shared'
6 5
6export const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
7export const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
8export const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
9export const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
10export const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
11export const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
12export const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
13export const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
14export const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
15export const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
16export const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
17export const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
18export const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
19export const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
20export const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
21export const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
22export const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
23export const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
24export const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
25export const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
26export const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
27export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
34
35export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
36
37// ---------------------------------------------------------------------------
38
7function checkSortFactory (columns: string[], tags: string[] = []) { 39function checkSortFactory (columns: string[], tags: string[] = []) {
8 return checkSort(createSortableColumns(columns), tags) 40 return checkSort(createSortableColumns(columns), tags)
9} 41}
@@ -27,64 +59,3 @@ function createSortableColumns (sortableColumns: string[]) {
27 59
28 return sortableColumns.concat(sortableColumnDesc) 60 return sortableColumns.concat(sortableColumnDesc)
29} 61}
30
31const adminUsersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ADMIN_USERS)
32const accountsSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS)
33const jobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.JOBS, [ 'jobs' ])
34const abusesSortValidator = checkSortFactory(SORTABLE_COLUMNS.ABUSES)
35const videosSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS)
36const videoImportsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_IMPORTS)
37const videosSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEOS_SEARCH)
38const videoChannelsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
39const videoPlaylistsSearchSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
40const videoCommentsValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENTS)
41const videoCommentThreadsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
42const videoRatesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_RATES)
43const blacklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.BLACKLISTS)
44const videoChannelsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNELS)
45const instanceFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWERS)
46const instanceFollowingSortValidator = checkSortFactory(SORTABLE_COLUMNS.INSTANCE_FOLLOWING)
47const userSubscriptionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
48const accountsBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
49const serversBlocklistSortValidator = checkSortFactory(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
50const userNotificationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
51const videoPlaylistsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PLAYLISTS)
52const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
53const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
54const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
55const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
56
57const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
58const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
59
60// ---------------------------------------------------------------------------
61
62export {
63 adminUsersSortValidator,
64 abusesSortValidator,
65 videoChannelsSortValidator,
66 videoImportsSortValidator,
67 videoCommentsValidator,
68 videosSearchSortValidator,
69 videosSortValidator,
70 blacklistSortValidator,
71 accountsSortValidator,
72 instanceFollowersSortValidator,
73 instanceFollowingSortValidator,
74 jobsSortValidator,
75 videoCommentThreadsSortValidator,
76 videoRatesSortValidator,
77 userSubscriptionsSortValidator,
78 availablePluginsSortValidator,
79 videoChannelsSearchSortValidator,
80 accountsBlocklistSortValidator,
81 serversBlocklistSortValidator,
82 userNotificationsSortValidator,
83 videoPlaylistsSortValidator,
84 videoRedundanciesSortValidator,
85 videoPlaylistsSearchSortValidator,
86 accountsFollowersSortValidator,
87 videoChannelsFollowersSortValidator,
88 videoChannelSyncsSortValidator,
89 pluginsSortValidator
90}
diff --git a/server/middlewares/validators/user-email-verification.ts b/server/middlewares/validators/user-email-verification.ts
new file mode 100644
index 000000000..74702a8f5
--- /dev/null
+++ b/server/middlewares/validators/user-email-verification.ts
@@ -0,0 +1,94 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { toBooleanOrNull } from '@server/helpers/custom-validators/misc'
4import { HttpStatusCode } from '@shared/models'
5import { logger } from '../../helpers/logger'
6import { Redis } from '../../lib/redis'
7import { areValidationErrors, checkUserEmailExist, checkUserIdExist } from './shared'
8import { checkRegistrationEmailExist, checkRegistrationIdExist } from './shared/user-registrations'
9
10const usersAskSendVerifyEmailValidator = [
11 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
12
13 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 if (areValidationErrors(req, res)) return
15
16 const [ userExists, registrationExists ] = await Promise.all([
17 checkUserEmailExist(req.body.email, res, false),
18 checkRegistrationEmailExist(req.body.email, res, false)
19 ])
20
21 if (!userExists && !registrationExists) {
22 logger.debug('User or registration with email %s does not exist (asking verify email).', req.body.email)
23 // Do not leak our emails
24 return res.status(HttpStatusCode.NO_CONTENT_204).end()
25 }
26
27 if (res.locals.user?.pluginAuth) {
28 return res.fail({
29 status: HttpStatusCode.CONFLICT_409,
30 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
31 })
32 }
33
34 return next()
35 }
36]
37
38const usersVerifyEmailValidator = [
39 param('id')
40 .isInt().not().isEmpty().withMessage('Should have a valid id'),
41
42 body('verificationString')
43 .not().isEmpty().withMessage('Should have a valid verification string'),
44 body('isPendingEmail')
45 .optional()
46 .customSanitizer(toBooleanOrNull),
47
48 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
49 if (areValidationErrors(req, res)) return
50 if (!await checkUserIdExist(req.params.id, res)) return
51
52 const user = res.locals.user
53 const redisVerificationString = await Redis.Instance.getUserVerifyEmailLink(user.id)
54
55 if (redisVerificationString !== req.body.verificationString) {
56 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
57 }
58
59 return next()
60 }
61]
62
63// ---------------------------------------------------------------------------
64
65const registrationVerifyEmailValidator = [
66 param('registrationId')
67 .isInt().not().isEmpty().withMessage('Should have a valid registrationId'),
68
69 body('verificationString')
70 .not().isEmpty().withMessage('Should have a valid verification string'),
71
72 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
73 if (areValidationErrors(req, res)) return
74 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
75
76 const registration = res.locals.userRegistration
77 const redisVerificationString = await Redis.Instance.getRegistrationVerifyEmailLink(registration.id)
78
79 if (redisVerificationString !== req.body.verificationString) {
80 return res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'Invalid verification string.' })
81 }
82
83 return next()
84 }
85]
86
87// ---------------------------------------------------------------------------
88
89export {
90 usersAskSendVerifyEmailValidator,
91 usersVerifyEmailValidator,
92
93 registrationVerifyEmailValidator
94}
diff --git a/server/middlewares/validators/user-registrations.ts b/server/middlewares/validators/user-registrations.ts
new file mode 100644
index 000000000..fcf655a2c
--- /dev/null
+++ b/server/middlewares/validators/user-registrations.ts
@@ -0,0 +1,208 @@
1import express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator'
3import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc'
4import { isRegistrationModerationResponseValid, isRegistrationReasonValid } from '@server/helpers/custom-validators/user-registration'
5import { CONFIG } from '@server/initializers/config'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { HttpStatusCode, UserRegister, UserRegistrationRequest, UserRegistrationState } from '@shared/models'
8import { isUserDisplayNameValid, isUserPasswordValid, isUserUsernameValid } from '../../helpers/custom-validators/users'
9import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
10import { isSignupAllowed, isSignupAllowedForCurrentIP, SignupMode } from '../../lib/signup'
11import { ActorModel } from '../../models/actor/actor'
12import { areValidationErrors, checkUserNameOrEmailDoNotAlreadyExist } from './shared'
13import { checkRegistrationHandlesDoNotAlreadyExist, checkRegistrationIdExist } from './shared/user-registrations'
14
15const usersDirectRegistrationValidator = usersCommonRegistrationValidatorFactory()
16
17const usersRequestRegistrationValidator = [
18 ...usersCommonRegistrationValidatorFactory([
19 body('registrationReason')
20 .custom(isRegistrationReasonValid)
21 ]),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 const body: UserRegistrationRequest = req.body
25
26 if (CONFIG.SIGNUP.REQUIRES_APPROVAL !== true) {
27 return res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Signup approval is not enabled on this instance'
30 })
31 }
32
33 const options = { username: body.username, email: body.email, channelHandle: body.channel?.name, res }
34 if (!await checkRegistrationHandlesDoNotAlreadyExist(options)) return
35
36 return next()
37 }
38]
39
40// ---------------------------------------------------------------------------
41
42function ensureUserRegistrationAllowedFactory (signupMode: SignupMode) {
43 return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
44 const allowedParams = {
45 body: req.body,
46 ip: req.ip,
47 signupMode
48 }
49
50 const allowedResult = await Hooks.wrapPromiseFun(
51 isSignupAllowed,
52 allowedParams,
53
54 signupMode === 'direct-registration'
55 ? 'filter:api.user.signup.allowed.result'
56 : 'filter:api.user.request-signup.allowed.result'
57 )
58
59 if (allowedResult.allowed === false) {
60 return res.fail({
61 status: HttpStatusCode.FORBIDDEN_403,
62 message: allowedResult.errorMessage || 'User registration is not enabled, user limit is reached or registration requires approval.'
63 })
64 }
65
66 return next()
67 }
68}
69
70const ensureUserRegistrationAllowedForIP = [
71 (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 const allowed = isSignupAllowedForCurrentIP(req.ip)
73
74 if (allowed === false) {
75 return res.fail({
76 status: HttpStatusCode.FORBIDDEN_403,
77 message: 'You are not on a network authorized for registration.'
78 })
79 }
80
81 return next()
82 }
83]
84
85// ---------------------------------------------------------------------------
86
87const acceptOrRejectRegistrationValidator = [
88 param('registrationId')
89 .custom(isIdValid),
90
91 body('moderationResponse')
92 .custom(isRegistrationModerationResponseValid),
93
94 body('preventEmailDelivery')
95 .optional()
96 .customSanitizer(toBooleanOrNull)
97 .custom(isBooleanValid).withMessage('Should have preventEmailDelivery boolean'),
98
99 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
100 if (areValidationErrors(req, res)) return
101 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
102
103 if (res.locals.userRegistration.state !== UserRegistrationState.PENDING) {
104 return res.fail({
105 status: HttpStatusCode.CONFLICT_409,
106 message: 'This registration is already accepted or rejected.'
107 })
108 }
109
110 return next()
111 }
112]
113
114// ---------------------------------------------------------------------------
115
116const getRegistrationValidator = [
117 param('registrationId')
118 .custom(isIdValid),
119
120 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
121 if (areValidationErrors(req, res)) return
122 if (!await checkRegistrationIdExist(req.params.registrationId, res)) return
123
124 return next()
125 }
126]
127
128// ---------------------------------------------------------------------------
129
130const listRegistrationsValidator = [
131 query('search')
132 .optional()
133 .custom(exists),
134
135 (req: express.Request, res: express.Response, next: express.NextFunction) => {
136 if (areValidationErrors(req, res)) return
137
138 return next()
139 }
140]
141
142// ---------------------------------------------------------------------------
143
144export {
145 usersDirectRegistrationValidator,
146 usersRequestRegistrationValidator,
147
148 ensureUserRegistrationAllowedFactory,
149 ensureUserRegistrationAllowedForIP,
150
151 getRegistrationValidator,
152 listRegistrationsValidator,
153
154 acceptOrRejectRegistrationValidator
155}
156
157// ---------------------------------------------------------------------------
158
159function usersCommonRegistrationValidatorFactory (additionalValidationChain: ValidationChain[] = []) {
160 return [
161 body('username')
162 .custom(isUserUsernameValid),
163 body('password')
164 .custom(isUserPasswordValid),
165 body('email')
166 .isEmail(),
167 body('displayName')
168 .optional()
169 .custom(isUserDisplayNameValid),
170
171 body('channel.name')
172 .optional()
173 .custom(isVideoChannelUsernameValid),
174 body('channel.displayName')
175 .optional()
176 .custom(isVideoChannelDisplayNameValid),
177
178 ...additionalValidationChain,
179
180 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
181 if (areValidationErrors(req, res, { omitBodyLog: true })) return
182
183 const body: UserRegister | UserRegistrationRequest = req.body
184
185 if (!await checkUserNameOrEmailDoNotAlreadyExist(body.username, body.email, res)) return
186
187 if (body.channel) {
188 if (!body.channel.name || !body.channel.displayName) {
189 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
190 }
191
192 if (body.channel.name === body.username) {
193 return res.fail({ message: 'Channel name cannot be the same as user username.' })
194 }
195
196 const existing = await ActorModel.loadLocalByName(body.channel.name)
197 if (existing) {
198 return res.fail({
199 status: HttpStatusCode.CONFLICT_409,
200 message: `Channel with name ${body.channel.name} already exists.`
201 })
202 }
203 }
204
205 return next()
206 }
207 ]
208}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 64bd9ca70..f7033f44a 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,8 +1,7 @@
1import express from 'express' 1import express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
5import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' 4import { HttpStatusCode, UserRight, UserRole } from '@shared/models'
6import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { 7import {
@@ -24,17 +23,16 @@ import {
24 isUserVideoQuotaValid, 23 isUserVideoQuotaValid,
25 isUserVideosHistoryEnabledValid 24 isUserVideosHistoryEnabledValid
26} from '../../helpers/custom-validators/users' 25} from '../../helpers/custom-validators/users'
27import { isVideoChannelDisplayNameValid, isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels' 26import { isVideoChannelUsernameValid } from '../../helpers/custom-validators/video-channels'
28import { logger } from '../../helpers/logger' 27import { logger } from '../../helpers/logger'
29import { isThemeRegistered } from '../../lib/plugins/theme-utils' 28import { isThemeRegistered } from '../../lib/plugins/theme-utils'
30import { Redis } from '../../lib/redis' 29import { Redis } from '../../lib/redis'
31import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
32import { ActorModel } from '../../models/actor/actor' 30import { ActorModel } from '../../models/actor/actor'
33import { 31import {
34 areValidationErrors, 32 areValidationErrors,
35 checkUserEmailExist, 33 checkUserEmailExist,
36 checkUserIdExist, 34 checkUserIdExist,
37 checkUserNameOrEmailDoesNotAlreadyExist, 35 checkUserNameOrEmailDoNotAlreadyExist,
38 doesVideoChannelIdExist, 36 doesVideoChannelIdExist,
39 doesVideoExist, 37 doesVideoExist,
40 isValidVideoIdParam 38 isValidVideoIdParam
@@ -81,7 +79,7 @@ const usersAddValidator = [
81 79
82 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
83 if (areValidationErrors(req, res, { omitBodyLog: true })) return 81 if (areValidationErrors(req, res, { omitBodyLog: true })) return
84 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return 82 if (!await checkUserNameOrEmailDoNotAlreadyExist(req.body.username, req.body.email, res)) return
85 83
86 const authUser = res.locals.oauth.token.User 84 const authUser = res.locals.oauth.token.User
87 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { 85 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
@@ -109,51 +107,6 @@ const usersAddValidator = [
109 } 107 }
110] 108]
111 109
112const usersRegisterValidator = [
113 body('username')
114 .custom(isUserUsernameValid),
115 body('password')
116 .custom(isUserPasswordValid),
117 body('email')
118 .isEmail(),
119 body('displayName')
120 .optional()
121 .custom(isUserDisplayNameValid),
122
123 body('channel.name')
124 .optional()
125 .custom(isVideoChannelUsernameValid),
126 body('channel.displayName')
127 .optional()
128 .custom(isVideoChannelDisplayNameValid),
129
130 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
131 if (areValidationErrors(req, res, { omitBodyLog: true })) return
132 if (!await checkUserNameOrEmailDoesNotAlreadyExist(req.body.username, req.body.email, res)) return
133
134 const body: UserRegister = req.body
135 if (body.channel) {
136 if (!body.channel.name || !body.channel.displayName) {
137 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
138 }
139
140 if (body.channel.name === body.username) {
141 return res.fail({ message: 'Channel name cannot be the same as user username.' })
142 }
143
144 const existing = await ActorModel.loadLocalByName(body.channel.name)
145 if (existing) {
146 return res.fail({
147 status: HttpStatusCode.CONFLICT_409,
148 message: `Channel with name ${body.channel.name} already exists.`
149 })
150 }
151 }
152
153 return next()
154 }
155]
156
157const usersRemoveValidator = [ 110const usersRemoveValidator = [
158 param('id') 111 param('id')
159 .custom(isIdValid), 112 .custom(isIdValid),
@@ -365,45 +318,6 @@ const usersVideosValidator = [
365 } 318 }
366] 319]
367 320
368const ensureUserRegistrationAllowed = [
369 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
370 const allowedParams = {
371 body: req.body,
372 ip: req.ip
373 }
374
375 const allowedResult = await Hooks.wrapPromiseFun(
376 isSignupAllowed,
377 allowedParams,
378 'filter:api.user.signup.allowed.result'
379 )
380
381 if (allowedResult.allowed === false) {
382 return res.fail({
383 status: HttpStatusCode.FORBIDDEN_403,
384 message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
385 })
386 }
387
388 return next()
389 }
390]
391
392const ensureUserRegistrationAllowedForIP = [
393 (req: express.Request, res: express.Response, next: express.NextFunction) => {
394 const allowed = isSignupAllowedForCurrentIP(req.ip)
395
396 if (allowed === false) {
397 return res.fail({
398 status: HttpStatusCode.FORBIDDEN_403,
399 message: 'You are not on a network authorized for registration.'
400 })
401 }
402
403 return next()
404 }
405]
406
407const usersAskResetPasswordValidator = [ 321const usersAskResetPasswordValidator = [
408 body('email') 322 body('email')
409 .isEmail(), 323 .isEmail(),
@@ -455,58 +369,6 @@ const usersResetPasswordValidator = [
455 } 369 }
456] 370]
457 371
458const usersAskSendVerifyEmailValidator = [
459 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
460
461 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
462 if (areValidationErrors(req, res)) return
463
464 const exists = await checkUserEmailExist(req.body.email, res, false)
465 if (!exists) {
466 logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
467 // Do not leak our emails
468 return res.status(HttpStatusCode.NO_CONTENT_204).end()
469 }
470
471 if (res.locals.user.pluginAuth) {
472 return res.fail({
473 status: HttpStatusCode.CONFLICT_409,
474 message: 'Cannot ask verification email of a user that uses a plugin authentication.'
475 })
476 }
477
478 return next()
479 }
480]
481
482const usersVerifyEmailValidator = [
483 param('id')
484 .isInt().not().isEmpty().withMessage('Should have a valid id'),
485
486 body('verificationString')
487 .not().isEmpty().withMessage('Should have a valid verification string'),
488 body('isPendingEmail')
489 .optional()
490 .customSanitizer(toBooleanOrNull),
491
492 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
493 if (areValidationErrors(req, res)) return
494 if (!await checkUserIdExist(req.params.id, res)) return
495
496 const user = res.locals.user
497 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
498
499 if (redisVerificationString !== req.body.verificationString) {
500 return res.fail({
501 status: HttpStatusCode.FORBIDDEN_403,
502 message: 'Invalid verification string.'
503 })
504 }
505
506 return next()
507 }
508]
509
510const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => { 372const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
511 return [ 373 return [
512 body('currentPassword').optional().custom(exists), 374 body('currentPassword').optional().custom(exists),
@@ -603,21 +465,16 @@ export {
603 usersListValidator, 465 usersListValidator,
604 usersAddValidator, 466 usersAddValidator,
605 deleteMeValidator, 467 deleteMeValidator,
606 usersRegisterValidator,
607 usersBlockingValidator, 468 usersBlockingValidator,
608 usersRemoveValidator, 469 usersRemoveValidator,
609 usersUpdateValidator, 470 usersUpdateValidator,
610 usersUpdateMeValidator, 471 usersUpdateMeValidator,
611 usersVideoRatingValidator, 472 usersVideoRatingValidator,
612 usersCheckCurrentPasswordFactory, 473 usersCheckCurrentPasswordFactory,
613 ensureUserRegistrationAllowed,
614 ensureUserRegistrationAllowedForIP,
615 usersGetValidator, 474 usersGetValidator,
616 usersVideosValidator, 475 usersVideosValidator,
617 usersAskResetPasswordValidator, 476 usersAskResetPasswordValidator,
618 usersResetPasswordValidator, 477 usersResetPasswordValidator,
619 usersAskSendVerifyEmailValidator,
620 usersVerifyEmailValidator,
621 userAutocompleteValidator, 478 userAutocompleteValidator,
622 ensureAuthUserOwnsAccountValidator, 479 ensureAuthUserOwnsAccountValidator,
623 ensureCanModerateUser, 480 ensureCanModerateUser,
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 20008768b..14a5bffa2 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -5,7 +5,7 @@ import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
5import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 7import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
8import { getSort, throwIfNotValid } from '../utils' 8import { getSort, throwIfNotValid } from '../shared'
9import { AbuseModel } from './abuse' 9import { AbuseModel } from './abuse'
10 10
11@Table({ 11@Table({
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 4c6a96a86..4ce40bf2f 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -34,13 +34,13 @@ import { AttributesOnly } from '@shared/typescript-utils'
34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' 34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' 35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37import { getSort, throwIfNotValid } from '../utils' 37import { getSort, throwIfNotValid } from '../shared'
38import { ThumbnailModel } from '../video/thumbnail' 38import { ThumbnailModel } from '../video/thumbnail'
39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' 39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40import { VideoBlacklistModel } from '../video/video-blacklist' 40import { VideoBlacklistModel } from '../video/video-blacklist'
41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' 41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' 42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder' 43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './sql/abuse-query-builder'
44import { VideoAbuseModel } from './video-abuse' 44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse' 45import { VideoCommentAbuseModel } from './video-comment-abuse'
46 46
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/sql/abuse-query-builder.ts
index 74f4542e5..282d4541a 100644
--- a/server/models/abuse/abuse-query-builder.ts
+++ b/server/models/abuse/sql/abuse-query-builder.ts
@@ -2,7 +2,7 @@
2import { exists } from '@server/helpers/custom-validators/misc' 2import { exists } from '@server/helpers/custom-validators/misc'
3import { forceNumber } from '@shared/core-utils' 3import { forceNumber } from '@shared/core-utils'
4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models' 4import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
5import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils' 5import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared'
6 6
7export type BuildAbusesQueryOptions = { 7export type BuildAbusesQueryOptions = {
8 start: number 8 start: number
@@ -157,7 +157,7 @@ function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' |
157} 157}
158 158
159function buildAbuseOrder (value: string) { 159function buildAbuseOrder (value: string) {
160 const { direction, field } = buildDirectionAndField(value) 160 const { direction, field } = buildSortDirectionAndField(value)
161 161
162 return `ORDER BY "abuse"."${field}" ${direction}` 162 return `ORDER BY "abuse"."${field}" ${direction}`
163} 163}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 377249b38..f6212ff6e 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -6,7 +6,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
7import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
8import { ServerModel } from '../server/server' 8import { ServerModel } from '../server/server'
9import { createSafeIn, getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../shared'
10import { AccountModel } from './account' 10import { AccountModel } from './account'
11 11
12@Table({ 12@Table({
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 7afc907da..9e7ef4394 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -11,7 +11,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 11import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 12import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
13import { ActorModel } from '../actor/actor' 13import { ActorModel } from '../actor/actor'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15import { VideoModel } from '../video/video' 15import { VideoModel } from '../video/video'
16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 16import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
17import { AccountModel } from './account' 17import { AccountModel } from './account'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 8a7dfba94..dc989417b 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -16,7 +16,7 @@ import {
16 Table, 16 Table,
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/shared/model-cache'
20import { AttributesOnly } from '@shared/typescript-utils' 20import { AttributesOnly } from '@shared/typescript-utils'
21import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
@@ -38,7 +38,7 @@ import { ApplicationModel } from '../application/application'
38import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
39import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user' 40import { UserModel } from '../user/user'
41import { getSort, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, getSort, throwIfNotValid } from '../shared'
42import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
43import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
44import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
@@ -251,6 +251,18 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
251 return undefined 251 return undefined
252 } 252 }
253 253
254 // ---------------------------------------------------------------------------
255
256 static getSQLAttributes (tableName: string, aliasPrefix = '') {
257 return buildSQLAttributes({
258 model: this,
259 tableName,
260 aliasPrefix
261 })
262 }
263
264 // ---------------------------------------------------------------------------
265
254 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> { 266 static load (id: number, transaction?: Transaction): Promise<MAccountDefault> {
255 return AccountModel.findByPk(id, { transaction }) 267 return AccountModel.findByPk(id, { transaction })
256 } 268 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 9615229dd..32e5d78b0 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -38,7 +38,7 @@ import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAM
38import { AccountModel } from '../account/account' 38import { AccountModel } from '../account/account'
39import { ServerModel } from '../server/server' 39import { ServerModel } from '../server/server'
40import { doesExist } from '../shared/query' 40import { doesExist } from '../shared/query'
41import { createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../utils' 41import { buildSQLAttributes, createSafeIn, getSort, searchAttribute, throwIfNotValid } from '../shared'
42import { VideoChannelModel } from '../video/video-channel' 42import { VideoChannelModel } from '../video/video-channel'
43import { ActorModel, unusedActorAttributesForAPI } from './actor' 43import { ActorModel, unusedActorAttributesForAPI } from './actor'
44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder' 44import { InstanceListFollowersQueryBuilder, ListFollowersOptions } from './sql/instance-list-followers-query-builder'
@@ -140,6 +140,18 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
140 }) 140 })
141 } 141 }
142 142
143 // ---------------------------------------------------------------------------
144
145 static getSQLAttributes (tableName: string, aliasPrefix = '') {
146 return buildSQLAttributes({
147 model: this,
148 tableName,
149 aliasPrefix
150 })
151 }
152
153 // ---------------------------------------------------------------------------
154
143 /* 155 /*
144 * @deprecated Use `findOrCreateCustom` instead 156 * @deprecated Use `findOrCreateCustom` instead
145 */ 157 */
@@ -213,7 +225,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
213 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` + 225 `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
214 `LIMIT 1` 226 `LIMIT 1`
215 227
216 return doesExist(query, { actorId, followerActorId }) 228 return doesExist(this.sequelize, query, { actorId, followerActorId })
217 } 229 }
218 230
219 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> { 231 static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index f2b3b2f4b..9c34a0101 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -22,7 +22,7 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants' 24import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
25import { throwIfNotValid } from '../utils' 25import { buildSQLAttributes, throwIfNotValid } from '../shared'
26import { ActorModel } from './actor' 26import { ActorModel } from './actor'
27 27
28@Table({ 28@Table({
@@ -94,6 +94,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err })) 94 .catch(err => logger.error('Cannot remove actor image file %s.', instance.filename, { err }))
95 } 95 }
96 96
97 // ---------------------------------------------------------------------------
98
99 static getSQLAttributes (tableName: string, aliasPrefix = '') {
100 return buildSQLAttributes({
101 model: this,
102 tableName,
103 aliasPrefix
104 })
105 }
106
107 // ---------------------------------------------------------------------------
108
97 static loadByName (filename: string) { 109 static loadByName (filename: string) {
98 const query = { 110 const query = {
99 where: { 111 where: {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index d7afa727d..1432e8757 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -17,7 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { activityPubContextify } from '@server/lib/activitypub/context' 18import { activityPubContextify } from '@server/lib/activitypub/context'
19import { getBiggestActorImage } from '@server/lib/actor-image' 19import { getBiggestActorImage } from '@server/lib/actor-image'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/shared/model-cache'
21import { forceNumber, getLowercaseExtension } from '@shared/core-utils' 21import { forceNumber, getLowercaseExtension } from '@shared/core-utils'
22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models' 22import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils' 23import { AttributesOnly } from '@shared/typescript-utils'
@@ -55,7 +55,7 @@ import {
55import { AccountModel } from '../account/account' 55import { AccountModel } from '../account/account'
56import { getServerActor } from '../application/application' 56import { getServerActor } from '../application/application'
57import { ServerModel } from '../server/server' 57import { ServerModel } from '../server/server'
58import { isOutdated, throwIfNotValid } from '../utils' 58import { buildSQLAttributes, isOutdated, throwIfNotValid } from '../shared'
59import { VideoModel } from '../video/video' 59import { VideoModel } from '../video/video'
60import { VideoChannelModel } from '../video/video-channel' 60import { VideoChannelModel } from '../video/video-channel'
61import { ActorFollowModel } from './actor-follow' 61import { ActorFollowModel } from './actor-follow'
@@ -65,7 +65,7 @@ enum ScopeNames {
65 FULL = 'FULL' 65 FULL = 'FULL'
66} 66}
67 67
68export const unusedActorAttributesForAPI = [ 68export const unusedActorAttributesForAPI: (keyof AttributesOnly<ActorModel>)[] = [
69 'publicKey', 69 'publicKey',
70 'privateKey', 70 'privateKey',
71 'inboxUrl', 71 'inboxUrl',
@@ -306,6 +306,27 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
306 }) 306 })
307 VideoChannel: VideoChannelModel 307 VideoChannel: VideoChannelModel
308 308
309 // ---------------------------------------------------------------------------
310
311 static getSQLAttributes (tableName: string, aliasPrefix = '') {
312 return buildSQLAttributes({
313 model: this,
314 tableName,
315 aliasPrefix
316 })
317 }
318
319 static getSQLAPIAttributes (tableName: string, aliasPrefix = '') {
320 return buildSQLAttributes({
321 model: this,
322 tableName,
323 aliasPrefix,
324 excludeAttributes: unusedActorAttributesForAPI
325 })
326 }
327
328 // ---------------------------------------------------------------------------
329
309 static async load (id: number): Promise<MActor> { 330 static async load (id: number): Promise<MActor> {
310 const actorServer = await getServerActor() 331 const actorServer = await getServerActor()
311 if (id === actorServer.id) return actorServer 332 if (id === actorServer.id) return actorServer
diff --git a/server/models/actor/sql/instance-list-followers-query-builder.ts b/server/models/actor/sql/instance-list-followers-query-builder.ts
index 4a17a8f11..34ce29b5d 100644
--- a/server/models/actor/sql/instance-list-followers-query-builder.ts
+++ b/server/models/actor/sql/instance-list-followers-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowersOptions { 8export interface ListFollowersOptions {
diff --git a/server/models/actor/sql/instance-list-following-query-builder.ts b/server/models/actor/sql/instance-list-following-query-builder.ts
index 880170b85..77b4e3dce 100644
--- a/server/models/actor/sql/instance-list-following-query-builder.ts
+++ b/server/models/actor/sql/instance-list-following-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { ModelBuilder } from '@server/models/shared' 2import { ModelBuilder } from '@server/models/shared'
3import { parseRowCountResult } from '@server/models/utils'
4import { MActorFollowActorsDefault } from '@server/types/models' 3import { MActorFollowActorsDefault } from '@server/types/models'
5import { ActivityPubActorType, FollowState } from '@shared/models' 4import { ActivityPubActorType, FollowState } from '@shared/models'
5import { parseRowCountResult } from '../../shared'
6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder' 6import { InstanceListFollowsQueryBuilder } from './shared/instance-list-follows-query-builder'
7 7
8export interface ListFollowingOptions { 8export interface ListFollowingOptions {
diff --git a/server/models/actor/sql/shared/actor-follow-table-attributes.ts b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
index 156b37d44..7dd908ece 100644
--- a/server/models/actor/sql/shared/actor-follow-table-attributes.ts
+++ b/server/models/actor/sql/shared/actor-follow-table-attributes.ts
@@ -1,62 +1,31 @@
1import { logger } from '@server/helpers/logger'
2import { Memoize } from '@server/helpers/memoize'
3import { ServerModel } from '@server/models/server/server'
4import { ActorModel } from '../../actor'
5import { ActorFollowModel } from '../../actor-follow'
6import { ActorImageModel } from '../../actor-image'
7
1export class ActorFollowTableAttributes { 8export class ActorFollowTableAttributes {
2 9
10 @Memoize()
3 getFollowAttributes () { 11 getFollowAttributes () {
4 return [ 12 logger.error('coucou')
5 '"ActorFollowModel"."id"', 13
6 '"ActorFollowModel"."state"', 14 return ActorFollowModel.getSQLAttributes('ActorFollowModel').join(', ')
7 '"ActorFollowModel"."score"',
8 '"ActorFollowModel"."url"',
9 '"ActorFollowModel"."actorId"',
10 '"ActorFollowModel"."targetActorId"',
11 '"ActorFollowModel"."createdAt"',
12 '"ActorFollowModel"."updatedAt"'
13 ].join(', ')
14 } 15 }
15 16
17 @Memoize()
16 getActorAttributes (actorTableName: string) { 18 getActorAttributes (actorTableName: string) {
17 return [ 19 return ActorModel.getSQLAttributes(actorTableName, `${actorTableName}.`).join(', ')
18 `"${actorTableName}"."id" AS "${actorTableName}.id"`,
19 `"${actorTableName}"."type" AS "${actorTableName}.type"`,
20 `"${actorTableName}"."preferredUsername" AS "${actorTableName}.preferredUsername"`,
21 `"${actorTableName}"."url" AS "${actorTableName}.url"`,
22 `"${actorTableName}"."publicKey" AS "${actorTableName}.publicKey"`,
23 `"${actorTableName}"."privateKey" AS "${actorTableName}.privateKey"`,
24 `"${actorTableName}"."followersCount" AS "${actorTableName}.followersCount"`,
25 `"${actorTableName}"."followingCount" AS "${actorTableName}.followingCount"`,
26 `"${actorTableName}"."inboxUrl" AS "${actorTableName}.inboxUrl"`,
27 `"${actorTableName}"."outboxUrl" AS "${actorTableName}.outboxUrl"`,
28 `"${actorTableName}"."sharedInboxUrl" AS "${actorTableName}.sharedInboxUrl"`,
29 `"${actorTableName}"."followersUrl" AS "${actorTableName}.followersUrl"`,
30 `"${actorTableName}"."followingUrl" AS "${actorTableName}.followingUrl"`,
31 `"${actorTableName}"."remoteCreatedAt" AS "${actorTableName}.remoteCreatedAt"`,
32 `"${actorTableName}"."serverId" AS "${actorTableName}.serverId"`,
33 `"${actorTableName}"."createdAt" AS "${actorTableName}.createdAt"`,
34 `"${actorTableName}"."updatedAt" AS "${actorTableName}.updatedAt"`
35 ].join(', ')
36 } 20 }
37 21
22 @Memoize()
38 getServerAttributes (actorTableName: string) { 23 getServerAttributes (actorTableName: string) {
39 return [ 24 return ServerModel.getSQLAttributes(`${actorTableName}->Server`, `${actorTableName}.Server.`).join(', ')
40 `"${actorTableName}->Server"."id" AS "${actorTableName}.Server.id"`,
41 `"${actorTableName}->Server"."host" AS "${actorTableName}.Server.host"`,
42 `"${actorTableName}->Server"."redundancyAllowed" AS "${actorTableName}.Server.redundancyAllowed"`,
43 `"${actorTableName}->Server"."createdAt" AS "${actorTableName}.Server.createdAt"`,
44 `"${actorTableName}->Server"."updatedAt" AS "${actorTableName}.Server.updatedAt"`
45 ].join(', ')
46 } 25 }
47 26
27 @Memoize()
48 getAvatarAttributes (actorTableName: string) { 28 getAvatarAttributes (actorTableName: string) {
49 return [ 29 return ActorImageModel.getSQLAttributes(`${actorTableName}->Avatars`, `${actorTableName}.Avatars.`).join(', ')
50 `"${actorTableName}->Avatars"."id" AS "${actorTableName}.Avatars.id"`,
51 `"${actorTableName}->Avatars"."filename" AS "${actorTableName}.Avatars.filename"`,
52 `"${actorTableName}->Avatars"."height" AS "${actorTableName}.Avatars.height"`,
53 `"${actorTableName}->Avatars"."width" AS "${actorTableName}.Avatars.width"`,
54 `"${actorTableName}->Avatars"."fileUrl" AS "${actorTableName}.Avatars.fileUrl"`,
55 `"${actorTableName}->Avatars"."onDisk" AS "${actorTableName}.Avatars.onDisk"`,
56 `"${actorTableName}->Avatars"."type" AS "${actorTableName}.Avatars.type"`,
57 `"${actorTableName}->Avatars"."actorId" AS "${actorTableName}.Avatars.actorId"`,
58 `"${actorTableName}->Avatars"."createdAt" AS "${actorTableName}.Avatars.createdAt"`,
59 `"${actorTableName}->Avatars"."updatedAt" AS "${actorTableName}.Avatars.updatedAt"`
60 ].join(', ')
61 } 30 }
62} 31}
diff --git a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
index 1d70fbe70..d9593e48b 100644
--- a/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
+++ b/server/models/actor/sql/shared/instance-list-follows-query-builder.ts
@@ -1,7 +1,7 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery } from '@server/models/shared' 2import { AbstractRunQuery } from '@server/models/shared'
3import { getInstanceFollowsSort } from '@server/models/utils'
4import { ActorImageType } from '@shared/models' 3import { ActorImageType } from '@shared/models'
4import { getInstanceFollowsSort } from '../../../shared'
5import { ActorFollowTableAttributes } from './actor-follow-table-attributes' 5import { ActorFollowTableAttributes } from './actor-follow-table-attributes'
6 6
7type BaseOptions = { 7type BaseOptions = {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 15909d5f3..c2a72b71f 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -34,7 +34,7 @@ import { CONFIG } from '../../initializers/config'
34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 34import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
35import { ActorModel } from '../actor/actor' 35import { ActorModel } from '../actor/actor'
36import { ServerModel } from '../server/server' 36import { ServerModel } from '../server/server'
37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 37import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../shared'
38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 38import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
39import { VideoModel } from '../video/video' 39import { VideoModel } from '../video/video'
40import { VideoChannelModel } from '../video/video-channel' 40import { VideoChannelModel } from '../video/video-channel'
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 71c205ffa..9948c9f7a 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -11,7 +11,7 @@ import {
11 isPluginStableVersionValid, 11 isPluginStableVersionValid,
12 isPluginTypeValid 12 isPluginTypeValid
13} from '../../helpers/custom-validators/plugins' 13} from '../../helpers/custom-validators/plugins'
14import { getSort, throwIfNotValid } from '../utils' 14import { getSort, throwIfNotValid } from '../shared'
15 15
16@DefaultScope(() => ({ 16@DefaultScope(() => ({
17 attributes: { 17 attributes: {
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 9752dfbc3..3d755fe4a 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -4,7 +4,7 @@ import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormat
4import { ServerBlock } from '@shared/models' 4import { ServerBlock } from '@shared/models'
5import { AttributesOnly } from '@shared/typescript-utils' 5import { AttributesOnly } from '@shared/typescript-utils'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { createSafeIn, getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../shared'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
9 9
10enum ScopeNames { 10enum ScopeNames {
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index ef42de090..a5e05f460 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -4,7 +4,7 @@ import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
6import { ActorModel } from '../actor/actor' 6import { ActorModel } from '../actor/actor'
7import { throwIfNotValid } from '../utils' 7import { buildSQLAttributes, throwIfNotValid } from '../shared'
8import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
9 9
10@Table({ 10@Table({
@@ -52,6 +52,18 @@ export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
52 }) 52 })
53 BlockedBy: ServerBlocklistModel[] 53 BlockedBy: ServerBlocklistModel[]
54 54
55 // ---------------------------------------------------------------------------
56
57 static getSQLAttributes (tableName: string, aliasPrefix = '') {
58 return buildSQLAttributes({
59 model: this,
60 tableName,
61 aliasPrefix
62 })
63 }
64
65 // ---------------------------------------------------------------------------
66
55 static load (id: number, transaction?: Transaction): Promise<MServer> { 67 static load (id: number, transaction?: Transaction): Promise<MServer> {
56 const query = { 68 const query = {
57 where: { 69 where: {
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts
index 04528929c..5a7621e4d 100644
--- a/server/models/shared/index.ts
+++ b/server/models/shared/index.ts
@@ -1,4 +1,8 @@
1export * from './abstract-run-query' 1export * from './abstract-run-query'
2export * from './model-builder' 2export * from './model-builder'
3export * from './model-cache'
3export * from './query' 4export * from './query'
5export * from './sequelize-helpers'
6export * from './sort'
7export * from './sql'
4export * from './update' 8export * from './update'
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
index c015ca4f5..07f7c4038 100644
--- a/server/models/shared/model-builder.ts
+++ b/server/models/shared/model-builder.ts
@@ -1,7 +1,24 @@
1import { isPlainObject } from 'lodash' 1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize' 2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4 4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
5export class ModelBuilder <T extends SequelizeModel> { 22export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>() 23 private readonly modelRegistry = new Map<string, T>()
7 24
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), 89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) } 90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 ) 91 )
75 return undefined 92 return { created: false, model: null }
76 } 93 }
77 94
78 // FIXME: typings 95 const model = Model.build(json, { raw: true, isNewRecord: false })
79 const model = new (Model as any)(json) 96
80 this.modelRegistry.set(registryKey, model) 97 this.modelRegistry.set(registryKey, model)
81 98
82 return { created: true, model } 99 return { created: true, model }
83 } 100 }
84 101
85 private findModelBuilder (modelName: string) { 102 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) 103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
87 } 104 }
88 105
89 private buildSequelizeModelName (modelName: string) { 106 private buildSequelizeModelName (modelName: string) {
diff --git a/server/models/model-cache.ts b/server/models/shared/model-cache.ts
index 3651267e7..3651267e7 100644
--- a/server/models/model-cache.ts
+++ b/server/models/shared/model-cache.ts
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts
index 036cc13c6..934acc21f 100644
--- a/server/models/shared/query.ts
+++ b/server/models/shared/query.ts
@@ -1,17 +1,82 @@
1import { BindOrReplacements, QueryTypes } from 'sequelize' 1import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database' 2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
3 4
4function doesExist (query: string, bind?: BindOrReplacements) { 5function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
5 const options = { 6 const options = {
6 type: QueryTypes.SELECT as QueryTypes.SELECT, 7 type: QueryTypes.SELECT as QueryTypes.SELECT,
7 bind, 8 bind,
8 raw: true 9 raw: true
9 } 10 }
10 11
11 return sequelizeTypescript.query(query, options) 12 return sequelize.query(query, options)
12 .then(results => results.length === 1) 13 .then(results => results.length === 1)
13} 14}
14 15
16function createSimilarityAttribute (col: string, value: string) {
17 return Sequelize.fn(
18 'similarity',
19
20 searchTrigramNormalizeCol(col),
21
22 searchTrigramNormalizeValue(value)
23 )
24}
25
26function buildWhereIdOrUUID (id: number | string) {
27 return validator.isInt('' + id) ? { id } : { uuid: id }
28}
29
30function parseAggregateResult (result: any) {
31 if (!result) return 0
32
33 const total = forceNumber(result)
34 if (isNaN(total)) return 0
35
36 return total
37}
38
39function parseRowCountResult (result: any) {
40 if (result.length !== 0) return result[0].total
41
42 return 0
43}
44
45function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
46 return toEscape.map(t => {
47 return t === null
48 ? null
49 : sequelize.escape('' + t)
50 }).concat(additionalUnescaped).join(', ')
51}
52
53function searchAttribute (sourceField?: string, targetField?: string) {
54 if (!sourceField) return {}
55
56 return {
57 [targetField]: {
58 // FIXME: ts error
59 [Op.iLike as any]: `%${sourceField}%`
60 }
61 }
62}
63
15export { 64export {
16 doesExist 65 doesExist,
66 createSimilarityAttribute,
67 buildWhereIdOrUUID,
68 parseAggregateResult,
69 parseRowCountResult,
70 createSafeIn,
71 searchAttribute
72}
73
74// ---------------------------------------------------------------------------
75
76function searchTrigramNormalizeValue (value: string) {
77 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
78}
79
80function searchTrigramNormalizeCol (col: string) {
81 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
17} 82}
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts
new file mode 100644
index 000000000..7af8471dc
--- /dev/null
+++ b/server/models/shared/sequelize-helpers.ts
@@ -0,0 +1,39 @@
1import { Sequelize } from 'sequelize'
2
3function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
4 if (!model.createdAt || !model.updatedAt) {
5 throw new Error('Miss createdAt & updatedAt attributes to model')
6 }
7
8 const now = Date.now()
9 const createdAtTime = model.createdAt.getTime()
10 const updatedAtTime = model.updatedAt.getTime()
11
12 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
13}
14
15function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
16 if (nullable && (value === null || value === undefined)) return
17
18 if (validator(value) === false) {
19 throw new Error(`"${value}" is not a valid ${fieldName}.`)
20 }
21}
22
23function buildTrigramSearchIndex (indexName: string, attribute: string) {
24 return {
25 name: indexName,
26 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
27 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
28 using: 'gin',
29 operator: 'gin_trgm_ops'
30 }
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 throwIfNotValid,
37 buildTrigramSearchIndex,
38 isOutdated
39}
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts
new file mode 100644
index 000000000..d923072f2
--- /dev/null
+++ b/server/models/shared/sort.ts
@@ -0,0 +1,146 @@
1import { literal, OrderItem, Sequelize } from 'sequelize'
2
3// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
4function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
5 const { direction, field } = buildSortDirectionAndField(value)
6
7 let finalField: string | ReturnType<typeof Sequelize.col>
8
9 if (field.toLowerCase() === 'match') { // Search
10 finalField = Sequelize.col('similarity')
11 } else {
12 finalField = field
13 }
14
15 return [ [ finalField, direction ], lastSort ]
16}
17
18function getAdminUsersSort (value: string): OrderItem[] {
19 const { direction, field } = buildSortDirectionAndField(value)
20
21 let finalField: string | ReturnType<typeof Sequelize.col>
22
23 if (field === 'videoQuotaUsed') { // Users list
24 finalField = Sequelize.col('videoQuotaUsed')
25 } else {
26 finalField = field
27 }
28
29 const nullPolicy = direction === 'ASC'
30 ? 'NULLS FIRST'
31 : 'NULLS LAST'
32
33 // FIXME: typings
34 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
35}
36
37function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
38 const { direction, field } = buildSortDirectionAndField(value)
39
40 if (field.toLowerCase() === 'name') {
41 return [ [ 'displayName', direction ], lastSort ]
42 }
43
44 return getSort(value, lastSort)
45}
46
47function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
48 const { direction, field } = buildSortDirectionAndField(value)
49
50 if (field.toLowerCase() === 'trending') { // Sort by aggregation
51 return [
52 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
53
54 [ Sequelize.col('VideoModel.views'), direction ],
55
56 lastSort
57 ]
58 } else if (field === 'publishedAt') {
59 return [
60 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
61
62 [ Sequelize.col('VideoModel.publishedAt'), direction ],
63
64 lastSort
65 ]
66 }
67
68 let finalField: string | ReturnType<typeof Sequelize.col>
69
70 // Alias
71 if (field.toLowerCase() === 'match') { // Search
72 finalField = Sequelize.col('similarity')
73 } else {
74 finalField = field
75 }
76
77 const firstSort: OrderItem = typeof finalField === 'string'
78 ? finalField.split('.').concat([ direction ]) as OrderItem
79 : [ finalField, direction ]
80
81 return [ firstSort, lastSort ]
82}
83
84function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
85 const { direction, field } = buildSortDirectionAndField(value)
86
87 const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ])
88
89 if (videoFields.has(field)) {
90 return [
91 [ literal(`"Video.${field}" ${direction}`) ],
92 lastSort
93 ] as OrderItem[]
94 }
95
96 return getSort(value, lastSort)
97}
98
99function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
100 const { direction, field } = buildSortDirectionAndField(value)
101
102 if (field === 'redundancyAllowed') {
103 return [
104 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
105 lastSort
106 ]
107 }
108
109 return getSort(value, lastSort)
110}
111
112function getChannelSyncSort (value: string): OrderItem[] {
113 const { direction, field } = buildSortDirectionAndField(value)
114 if (field.toLowerCase() === 'videochannel') {
115 return [
116 [ literal('"VideoChannel.name"'), direction ]
117 ]
118 }
119 return [ [ field, direction ] ]
120}
121
122function buildSortDirectionAndField (value: string) {
123 let field: string
124 let direction: 'ASC' | 'DESC'
125
126 if (value.substring(0, 1) === '-') {
127 direction = 'DESC'
128 field = value.substring(1)
129 } else {
130 direction = 'ASC'
131 field = value
132 }
133
134 return { direction, field }
135}
136
137export {
138 buildSortDirectionAndField,
139 getPlaylistSort,
140 getSort,
141 getAdminUsersSort,
142 getVideoSort,
143 getBlacklistSort,
144 getChannelSyncSort,
145 getInstanceFollowsSort
146}
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts
new file mode 100644
index 000000000..5aaeb49f0
--- /dev/null
+++ b/server/models/shared/sql.ts
@@ -0,0 +1,68 @@
1import { literal, Model, ModelStatic } from 'sequelize'
2import { forceNumber } from '@shared/core-utils'
3import { AttributesOnly } from '@shared/typescript-utils'
4
5function buildLocalAccountIdsIn () {
6 return literal(
7 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
8 )
9}
10
11function buildLocalActorIdsIn () {
12 return literal(
13 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
14 )
15}
16
17function buildBlockedAccountSQL (blockerIds: number[]) {
18 const blockerIdsString = blockerIds.join(', ')
19
20 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
21 ' UNION ' +
22 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
23 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
24 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
25}
26
27function buildServerIdsFollowedBy (actorId: any) {
28 const actorIdNumber = forceNumber(actorId)
29
30 return '(' +
31 'SELECT "actor"."serverId" FROM "actorFollow" ' +
32 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
33 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
34 ')'
35}
36
37function buildSQLAttributes<M extends Model> (options: {
38 model: ModelStatic<M>
39 tableName: string
40
41 excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
42 aliasPrefix?: string
43}) {
44 const { model, tableName, aliasPrefix, excludeAttributes } = options
45
46 const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
47
48 return attributes
49 .filter(a => {
50 if (!excludeAttributes) return true
51 if (excludeAttributes.includes(a)) return false
52
53 return true
54 })
55 .map(a => {
56 return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
57 })
58}
59
60// ---------------------------------------------------------------------------
61
62export {
63 buildSQLAttributes,
64 buildBlockedAccountSQL,
65 buildServerIdsFollowedBy,
66 buildLocalAccountIdsIn,
67 buildLocalActorIdsIn
68}
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts
index d338211e3..d02c4535d 100644
--- a/server/models/shared/update.ts
+++ b/server/models/shared/update.ts
@@ -1,9 +1,15 @@
1import { QueryTypes, Transaction } from 'sequelize' 1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3 2
4// Sequelize always skip the update if we only update updatedAt field 3// Sequelize always skip the update if we only update updatedAt field
5function setAsUpdated (table: string, id: number, transaction?: Transaction) { 4function setAsUpdated (options: {
6 return sequelizeTypescript.query( 5 sequelize: Sequelize
6 table: string
7 id: number
8 transaction?: Transaction
9}) {
10 const { sequelize, table, id, transaction } = options
11
12 return sequelize.query(
7 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, 13 `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`,
8 { 14 {
9 replacements: { table, id, updatedAt: new Date() }, 15 replacements: { table, id, updatedAt: new Date() },
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 31b4932bf..7b29807a3 100644
--- a/server/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/models/user/sql/user-notitication-list-query-builder.ts
@@ -1,8 +1,8 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' 2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { getSort } from '@server/models/utils'
4import { UserNotificationModelForApi } from '@server/types/models' 3import { UserNotificationModelForApi } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
5import { getSort } from '../../shared'
6 6
7export interface ListNotificationsOptions { 7export interface ListNotificationsOptions {
8 userId: number 8 userId: number
@@ -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-setting.ts b/server/models/user/user-notification-setting.ts
index 66e1d85b3..394494c0c 100644
--- a/server/models/user/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -17,7 +17,7 @@ import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/typescript-utils' 17import { AttributesOnly } from '@shared/typescript-utils'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
20import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../shared'
21import { UserModel } from './user' 21import { UserModel } from './user'
22 22
23@Table({ 23@Table({
diff --git a/server/models/user/user-notification.ts b/server/models/user/user-notification.ts
index d37fa5dc7..667ee7f5f 100644
--- a/server/models/user/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -13,13 +13,14 @@ import { AccountModel } from '../account/account'
13import { ActorFollowModel } from '../actor/actor-follow' 13import { ActorFollowModel } from '../actor/actor-follow'
14import { ApplicationModel } from '../application/application' 14import { ApplicationModel } from '../application/application'
15import { PluginModel } from '../server/plugin' 15import { PluginModel } from '../server/plugin'
16import { throwIfNotValid } from '../utils' 16import { throwIfNotValid } from '../shared'
17import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
18import { VideoBlacklistModel } from '../video/video-blacklist' 18import { VideoBlacklistModel } from '../video/video-blacklist'
19import { VideoCommentModel } from '../video/video-comment' 19import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import' 20import { VideoImportModel } from '../video/video-import'
21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder' 21import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
22import { UserModel } from './user' 22import { UserModel } from './user'
23import { UserRegistrationModel } from './user-registration'
23 24
24@Table({ 25@Table({
25 tableName: 'userNotification', 26 tableName: 'userNotification',
@@ -98,6 +99,14 @@ import { UserModel } from './user'
98 [Op.ne]: null 99 [Op.ne]: null
99 } 100 }
100 } 101 }
102 },
103 {
104 fields: [ 'userRegistrationId' ],
105 where: {
106 userRegistrationId: {
107 [Op.ne]: null
108 }
109 }
101 } 110 }
102 ] as (ModelIndexesOptions & { where?: WhereOptions })[] 111 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
103}) 112})
@@ -241,6 +250,18 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
241 }) 250 })
242 Application: ApplicationModel 251 Application: ApplicationModel
243 252
253 @ForeignKey(() => UserRegistrationModel)
254 @Column
255 userRegistrationId: number
256
257 @BelongsTo(() => UserRegistrationModel, {
258 foreignKey: {
259 allowNull: true
260 },
261 onDelete: 'cascade'
262 })
263 UserRegistration: UserRegistrationModel
264
244 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 265 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
245 const where = { userId } 266 const where = { userId }
246 267
@@ -416,6 +437,10 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
416 ? { latestVersion: this.Application.latestPeerTubeVersion } 437 ? { latestVersion: this.Application.latestPeerTubeVersion }
417 : undefined 438 : undefined
418 439
440 const registration = this.UserRegistration
441 ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
442 : undefined
443
419 return { 444 return {
420 id: this.id, 445 id: this.id,
421 type: this.type, 446 type: this.type,
@@ -429,6 +454,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
429 actorFollow, 454 actorFollow,
430 plugin, 455 plugin,
431 peertube, 456 peertube,
457 registration,
432 createdAt: this.createdAt.toISOString(), 458 createdAt: this.createdAt.toISOString(),
433 updatedAt: this.updatedAt.toISOString() 459 updatedAt: this.updatedAt.toISOString()
434 } 460 }
diff --git a/server/models/user/user-registration.ts b/server/models/user/user-registration.ts
new file mode 100644
index 000000000..adda3cc7e
--- /dev/null
+++ b/server/models/user/user-registration.ts
@@ -0,0 +1,259 @@
1import { FindOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeCreate,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 IsEmail,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import {
17 isRegistrationModerationResponseValid,
18 isRegistrationReasonValid,
19 isRegistrationStateValid
20} from '@server/helpers/custom-validators/user-registration'
21import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels'
22import { cryptPassword } from '@server/helpers/peertube-crypto'
23import { USER_REGISTRATION_STATES } from '@server/initializers/constants'
24import { MRegistration, MRegistrationFormattable } from '@server/types/models'
25import { UserRegistration, UserRegistrationState } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users'
28import { getSort, throwIfNotValid } from '../shared'
29import { UserModel } from './user'
30
31@Table({
32 tableName: 'userRegistration',
33 indexes: [
34 {
35 fields: [ 'username' ],
36 unique: true
37 },
38 {
39 fields: [ 'email' ],
40 unique: true
41 },
42 {
43 fields: [ 'channelHandle' ],
44 unique: true
45 },
46 {
47 fields: [ 'userId' ],
48 unique: true
49 }
50 ]
51})
52export class UserRegistrationModel extends Model<Partial<AttributesOnly<UserRegistrationModel>>> {
53
54 @AllowNull(false)
55 @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
56 @Column
57 state: UserRegistrationState
58
59 @AllowNull(false)
60 @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
61 @Column(DataType.TEXT)
62 registrationReason: string
63
64 @AllowNull(true)
65 @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
66 @Column(DataType.TEXT)
67 moderationResponse: string
68
69 @AllowNull(true)
70 @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
71 @Column
72 password: string
73
74 @AllowNull(false)
75 @Column
76 username: string
77
78 @AllowNull(false)
79 @IsEmail
80 @Column(DataType.STRING(400))
81 email: string
82
83 @AllowNull(true)
84 @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
85 @Column
86 emailVerified: boolean
87
88 @AllowNull(true)
89 @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
90 @Column
91 accountDisplayName: string
92
93 @AllowNull(true)
94 @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
95 @Column
96 channelHandle: string
97
98 @AllowNull(true)
99 @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
100 @Column
101 channelDisplayName: string
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: true
116 },
117 onDelete: 'SET NULL'
118 })
119 User: UserModel
120
121 @BeforeCreate
122 static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
123 instance.password = await cryptPassword(instance.password)
124 }
125
126 static load (id: number): Promise<MRegistration> {
127 return UserRegistrationModel.findByPk(id)
128 }
129
130 static loadByEmail (email: string): Promise<MRegistration> {
131 const query = {
132 where: { email }
133 }
134
135 return UserRegistrationModel.findOne(query)
136 }
137
138 static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
139 const query = {
140 where: {
141 [Op.or]: [
142 { email: emailOrUsername },
143 { username: emailOrUsername }
144 ]
145 }
146 }
147
148 return UserRegistrationModel.findOne(query)
149 }
150
151 static loadByEmailOrHandle (options: {
152 email: string
153 username: string
154 channelHandle?: string
155 }): Promise<MRegistration> {
156 const { email, username, channelHandle } = options
157
158 let or: WhereOptions = [
159 { email },
160 { channelHandle: username },
161 { username }
162 ]
163
164 if (channelHandle) {
165 or = or.concat([
166 { username: channelHandle },
167 { channelHandle }
168 ])
169 }
170
171 const query = {
172 where: {
173 [Op.or]: or
174 }
175 }
176
177 return UserRegistrationModel.findOne(query)
178 }
179
180 // ---------------------------------------------------------------------------
181
182 static listForApi (options: {
183 start: number
184 count: number
185 sort: string
186 search?: string
187 }) {
188 const { start, count, sort, search } = options
189
190 const where: WhereOptions = {}
191
192 if (search) {
193 Object.assign(where, {
194 [Op.or]: [
195 {
196 email: {
197 [Op.iLike]: '%' + search + '%'
198 }
199 },
200 {
201 username: {
202 [Op.iLike]: '%' + search + '%'
203 }
204 }
205 ]
206 })
207 }
208
209 const query: FindOptions = {
210 offset: start,
211 limit: count,
212 order: getSort(sort),
213 where,
214 include: [
215 {
216 model: UserModel.unscoped(),
217 required: false
218 }
219 ]
220 }
221
222 return Promise.all([
223 UserRegistrationModel.count(query),
224 UserRegistrationModel.findAll<MRegistrationFormattable>(query)
225 ]).then(([ total, data ]) => ({ total, data }))
226 }
227
228 // ---------------------------------------------------------------------------
229
230 toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
231 return {
232 id: this.id,
233
234 state: {
235 id: this.state,
236 label: USER_REGISTRATION_STATES[this.state]
237 },
238
239 registrationReason: this.registrationReason,
240 moderationResponse: this.moderationResponse,
241
242 username: this.username,
243 email: this.email,
244 emailVerified: this.emailVerified,
245
246 accountDisplayName: this.accountDisplayName,
247
248 channelHandle: this.channelHandle,
249 channelDisplayName: this.channelDisplayName,
250
251 createdAt: this.createdAt,
252 updatedAt: this.updatedAt,
253
254 user: this.User
255 ? { id: this.User.id }
256 : null
257 }
258 }
259}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 3fd808edc..bfc9b3049 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -30,6 +30,7 @@ import {
30 MUserNotifSettingChannelDefault, 30 MUserNotifSettingChannelDefault,
31 MUserWithNotificationSetting 31 MUserWithNotificationSetting
32} from '@server/types/models' 32} from '@server/types/models'
33import { forceNumber } from '@shared/core-utils'
33import { AttributesOnly } from '@shared/typescript-utils' 34import { AttributesOnly } from '@shared/typescript-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType } from '../../../shared/models'
@@ -63,14 +64,13 @@ import { ActorModel } from '../actor/actor'
63import { ActorFollowModel } from '../actor/actor-follow' 64import { ActorFollowModel } from '../actor/actor-follow'
64import { ActorImageModel } from '../actor/actor-image' 65import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 66import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getAdminUsersSort, throwIfNotValid } from '../utils' 67import { getAdminUsersSort, throwIfNotValid } from '../shared'
67import { VideoModel } from '../video/video' 68import { VideoModel } from '../video/video'
68import { VideoChannelModel } from '../video/video-channel' 69import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 70import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 71import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 72import { VideoPlaylistModel } from '../video/video-playlist'
72import { UserNotificationSettingModel } from './user-notification-setting' 73import { UserNotificationSettingModel } from './user-notification-setting'
73import { forceNumber } from '@shared/core-utils'
74 74
75enum ScopeNames { 75enum ScopeNames {
76 FOR_ME_API = 'FOR_ME_API', 76 FOR_ME_API = 'FOR_ME_API',
@@ -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/models/utils.ts b/server/models/utils.ts
deleted file mode 100644
index 3476799ce..000000000
--- a/server/models/utils.ts
+++ /dev/null
@@ -1,317 +0,0 @@
1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import validator from 'validator'
3import { forceNumber } from '@shared/core-utils'
4
5type SortType = { sortModel: string, sortValue: string }
6
7// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
8function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
9 const { direction, field } = buildDirectionAndField(value)
10
11 let finalField: string | ReturnType<typeof Sequelize.col>
12
13 if (field.toLowerCase() === 'match') { // Search
14 finalField = Sequelize.col('similarity')
15 } else {
16 finalField = field
17 }
18
19 return [ [ finalField, direction ], lastSort ]
20}
21
22function getAdminUsersSort (value: string): OrderItem[] {
23 const { direction, field } = buildDirectionAndField(value)
24
25 let finalField: string | ReturnType<typeof Sequelize.col>
26
27 if (field === 'videoQuotaUsed') { // Users list
28 finalField = Sequelize.col('videoQuotaUsed')
29 } else {
30 finalField = field
31 }
32
33 const nullPolicy = direction === 'ASC'
34 ? 'NULLS FIRST'
35 : 'NULLS LAST'
36
37 // FIXME: typings
38 return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ]
39}
40
41function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
42 const { direction, field } = buildDirectionAndField(value)
43
44 if (field.toLowerCase() === 'name') {
45 return [ [ 'displayName', direction ], lastSort ]
46 }
47
48 return getSort(value, lastSort)
49}
50
51function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
52 const { direction, field } = buildDirectionAndField(value)
53
54 if (field === 'totalReplies') {
55 return [
56 [ Sequelize.literal('"totalReplies"'), direction ],
57 lastSort
58 ]
59 }
60
61 return getSort(value, lastSort)
62}
63
64function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
65 const { direction, field } = buildDirectionAndField(value)
66
67 if (field.toLowerCase() === 'trending') { // Sort by aggregation
68 return [
69 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ],
70
71 [ Sequelize.col('VideoModel.views'), direction ],
72
73 lastSort
74 ]
75 } else if (field === 'publishedAt') {
76 return [
77 [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ],
78
79 [ Sequelize.col('VideoModel.publishedAt'), direction ],
80
81 lastSort
82 ]
83 }
84
85 let finalField: string | ReturnType<typeof Sequelize.col>
86
87 // Alias
88 if (field.toLowerCase() === 'match') { // Search
89 finalField = Sequelize.col('similarity')
90 } else {
91 finalField = field
92 }
93
94 const firstSort: OrderItem = typeof finalField === 'string'
95 ? finalField.split('.').concat([ direction ]) as OrderItem
96 : [ finalField, direction ]
97
98 return [ firstSort, lastSort ]
99}
100
101function getBlacklistSort (model: any, value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
102 const [ firstSort ] = getSort(value)
103
104 if (model) return [ [ literal(`"${model}.${firstSort[0]}" ${firstSort[1]}`) ], lastSort ] as OrderItem[]
105 return [ firstSort, lastSort ]
106}
107
108function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] {
109 const { direction, field } = buildDirectionAndField(value)
110
111 if (field === 'redundancyAllowed') {
112 return [
113 [ 'ActorFollowing.Server.redundancyAllowed', direction ],
114 lastSort
115 ]
116 }
117
118 return getSort(value, lastSort)
119}
120
121function getChannelSyncSort (value: string): OrderItem[] {
122 const { direction, field } = buildDirectionAndField(value)
123 if (field.toLowerCase() === 'videochannel') {
124 return [
125 [ literal('"VideoChannel.name"'), direction ]
126 ]
127 }
128 return [ [ field, direction ] ]
129}
130
131function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
132 if (!model.createdAt || !model.updatedAt) {
133 throw new Error('Miss createdAt & updatedAt attributes to model')
134 }
135
136 const now = Date.now()
137 const createdAtTime = model.createdAt.getTime()
138 const updatedAtTime = model.updatedAt.getTime()
139
140 return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval
141}
142
143function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) {
144 if (nullable && (value === null || value === undefined)) return
145
146 if (validator(value) === false) {
147 throw new Error(`"${value}" is not a valid ${fieldName}.`)
148 }
149}
150
151function buildTrigramSearchIndex (indexName: string, attribute: string) {
152 return {
153 name: indexName,
154 // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function
155 fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ],
156 using: 'gin',
157 operator: 'gin_trgm_ops'
158 }
159}
160
161function createSimilarityAttribute (col: string, value: string) {
162 return Sequelize.fn(
163 'similarity',
164
165 searchTrigramNormalizeCol(col),
166
167 searchTrigramNormalizeValue(value)
168 )
169}
170
171function buildBlockedAccountSQL (blockerIds: number[]) {
172 const blockerIdsString = blockerIds.join(', ')
173
174 return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
175 ' UNION ' +
176 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' +
177 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' +
178 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
179}
180
181function buildBlockedAccountSQLOptimized (columnNameJoin: string, blockerIds: number[]) {
182 const blockerIdsString = blockerIds.join(', ')
183
184 return [
185 literal(
186 `NOT EXISTS (` +
187 ` SELECT 1 FROM "accountBlocklist" ` +
188 ` WHERE "targetAccountId" = ${columnNameJoin} ` +
189 ` AND "accountId" IN (${blockerIdsString})` +
190 `)`
191 ),
192
193 literal(
194 `NOT EXISTS (` +
195 ` SELECT 1 FROM "account" ` +
196 ` INNER JOIN "actor" ON account."actorId" = actor.id ` +
197 ` INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
198 ` WHERE "account"."id" = ${columnNameJoin} ` +
199 ` AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
200 `)`
201 )
202 ]
203}
204
205function buildServerIdsFollowedBy (actorId: any) {
206 const actorIdNumber = forceNumber(actorId)
207
208 return '(' +
209 'SELECT "actor"."serverId" FROM "actorFollow" ' +
210 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
211 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
212 ')'
213}
214
215function buildWhereIdOrUUID (id: number | string) {
216 return validator.isInt('' + id) ? { id } : { uuid: id }
217}
218
219function parseAggregateResult (result: any) {
220 if (!result) return 0
221
222 const total = forceNumber(result)
223 if (isNaN(total)) return 0
224
225 return total
226}
227
228function parseRowCountResult (result: any) {
229 if (result.length !== 0) return result[0].total
230
231 return 0
232}
233
234function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
235 return stringArr.map(t => {
236 return t === null
237 ? null
238 : sequelize.escape('' + t)
239 }).join(', ')
240}
241
242function buildLocalAccountIdsIn () {
243 return literal(
244 '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
245 )
246}
247
248function buildLocalActorIdsIn () {
249 return literal(
250 '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
251 )
252}
253
254function buildDirectionAndField (value: string) {
255 let field: string
256 let direction: 'ASC' | 'DESC'
257
258 if (value.substring(0, 1) === '-') {
259 direction = 'DESC'
260 field = value.substring(1)
261 } else {
262 direction = 'ASC'
263 field = value
264 }
265
266 return { direction, field }
267}
268
269function searchAttribute (sourceField?: string, targetField?: string) {
270 if (!sourceField) return {}
271
272 return {
273 [targetField]: {
274 // FIXME: ts error
275 [Op.iLike as any]: `%${sourceField}%`
276 }
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 buildBlockedAccountSQL,
284 buildBlockedAccountSQLOptimized,
285 buildLocalActorIdsIn,
286 getPlaylistSort,
287 SortType,
288 buildLocalAccountIdsIn,
289 getSort,
290 getCommentSort,
291 getAdminUsersSort,
292 getVideoSort,
293 getBlacklistSort,
294 getChannelSyncSort,
295 createSimilarityAttribute,
296 throwIfNotValid,
297 buildServerIdsFollowedBy,
298 buildTrigramSearchIndex,
299 buildWhereIdOrUUID,
300 isOutdated,
301 parseAggregateResult,
302 getInstanceFollowsSort,
303 buildDirectionAndField,
304 createSafeIn,
305 searchAttribute,
306 parseRowCountResult
307}
308
309// ---------------------------------------------------------------------------
310
311function searchTrigramNormalizeValue (value: string) {
312 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value))
313}
314
315function searchTrigramNormalizeCol (col: string) {
316 return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col)))
317}
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index f285db477..6f05dbdc8 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -488,7 +488,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
488} 488}
489 489
490function getCategoryLabel (id: number) { 490function getCategoryLabel (id: number) {
491 return VIDEO_CATEGORIES[id] || 'Misc' 491 return VIDEO_CATEGORIES[id] || 'Unknown'
492} 492}
493 493
494function getLicenceLabel (id: number) { 494function getLicenceLabel (id: number) {
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644
index 000000000..a7eed22a1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -0,0 +1,400 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerLateralJoins = ''
48 private innerWhere = ''
49
50 private readonly built = {
51 cte: false,
52 accountJoin: false,
53 videoJoin: false,
54 videoChannelJoin: false,
55 avatarJoin: false
56 }
57
58 constructor (
59 protected readonly sequelize: Sequelize,
60 private readonly options: ListVideoCommentsOptions
61 ) {
62 super(sequelize)
63
64 if (this.options.includeReplyCounters && !this.options.videoId) {
65 throw new Error('Cannot include reply counters without videoId')
66 }
67 }
68
69 async listComments <T extends Model> () {
70 this.buildListQuery()
71
72 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
73 const modelBuilder = new ModelBuilder<T>(this.sequelize)
74
75 return modelBuilder.createModels(results, 'VideoComment')
76 }
77
78 async countComments () {
79 this.buildCountQuery()
80
81 const result = await this.runQuery({ transaction: this.options.transaction })
82
83 return parseRowCountResult(result)
84 }
85
86 // ---------------------------------------------------------------------------
87
88 private buildListQuery () {
89 this.buildInnerListQuery()
90 this.buildListSelect()
91
92 this.query = `${this.select} ` +
93 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
94 `${this.joins} ` +
95 `${this.getOrder()}`
96 }
97
98 private buildInnerListQuery () {
99 this.buildWhere()
100 this.buildInnerListSelect()
101
102 this.innerQuery = `${this.innerSelect} ` +
103 `FROM "videoComment" AS "VideoCommentModel" ` +
104 `${this.innerJoins} ` +
105 `${this.innerLateralJoins} ` +
106 `${this.innerWhere} ` +
107 `${this.getOrder()} ` +
108 `${this.getInnerLimit()}`
109 }
110
111 // ---------------------------------------------------------------------------
112
113 private buildCountQuery () {
114 this.buildWhere()
115
116 this.query = `SELECT COUNT(*) AS "total" ` +
117 `FROM "videoComment" AS "VideoCommentModel" ` +
118 `${this.innerJoins} ` +
119 `${this.innerWhere}`
120 }
121
122 // ---------------------------------------------------------------------------
123
124 private buildWhere () {
125 let where: string[] = []
126
127 if (this.options.videoId) {
128 this.replacements.videoId = this.options.videoId
129
130 where.push('"VideoCommentModel"."videoId" = :videoId')
131 }
132
133 if (this.options.threadId) {
134 this.replacements.threadId = this.options.threadId
135
136 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
137 }
138
139 if (this.options.accountId) {
140 this.replacements.accountId = this.options.accountId
141
142 where.push('"VideoCommentModel"."accountId" = :accountId')
143 }
144
145 if (this.options.videoChannelId) {
146 this.buildVideoChannelJoin()
147
148 this.replacements.videoChannelId = this.options.videoChannelId
149
150 where.push('"Account->VideoChannel"."id" = :videoChannelId')
151 }
152
153 if (this.options.blockerAccountIds) {
154 this.buildVideoChannelJoin()
155
156 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
157 }
158
159 if (this.options.isThread === true) {
160 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
161 }
162
163 if (this.options.notDeleted === true) {
164 where.push('"VideoCommentModel"."deletedAt" IS NULL')
165 }
166
167 if (this.options.isLocal === true) {
168 this.buildAccountJoin()
169
170 where.push('"Account->Actor"."serverId" IS NULL')
171 } else if (this.options.isLocal === false) {
172 this.buildAccountJoin()
173
174 where.push('"Account->Actor"."serverId" IS NOT NULL')
175 }
176
177 if (this.options.onLocalVideo === true) {
178 this.buildVideoJoin()
179
180 where.push('"Video"."remote" IS FALSE')
181 } else if (this.options.onLocalVideo === false) {
182 this.buildVideoJoin()
183
184 where.push('"Video"."remote" IS TRUE')
185 }
186
187 if (this.options.onPublicVideo === true) {
188 this.buildVideoJoin()
189
190 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
191 }
192
193 if (this.options.videoAccountOwnerId) {
194 this.buildVideoChannelJoin()
195
196 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
197
198 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
199 }
200
201 if (this.options.search) {
202 this.buildVideoJoin()
203 this.buildAccountJoin()
204
205 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
206
207 where.push(
208 `(` +
209 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
210 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
211 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
212 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
213 `)`
214 )
215 }
216
217 if (this.options.searchAccount) {
218 this.buildAccountJoin()
219
220 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
221
222 where.push(
223 `(` +
224 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
225 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
226 `)`
227 )
228 }
229
230 if (this.options.searchVideo) {
231 this.buildVideoJoin()
232
233 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
234
235 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
236 }
237
238 if (where.length !== 0) {
239 this.innerWhere = `WHERE ${where.join(' AND ')}`
240 }
241 }
242
243 private buildAccountJoin () {
244 if (this.built.accountJoin) return
245
246 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
247 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
248 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
249
250 this.built.accountJoin = true
251 }
252
253 private buildVideoJoin () {
254 if (this.built.videoJoin) return
255
256 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
257
258 this.built.videoJoin = true
259 }
260
261 private buildVideoChannelJoin () {
262 if (this.built.videoChannelJoin) return
263
264 this.buildVideoJoin()
265
266 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
267
268 this.built.videoChannelJoin = true
269 }
270
271 private buildAvatarsJoin () {
272 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
273 if (this.built.avatarJoin) return
274
275 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
276 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
277 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
278
279 this.built.avatarJoin = true
280 }
281
282 // ---------------------------------------------------------------------------
283
284 private buildListSelect () {
285 const toSelect = [ '"VideoCommentModel".*' ]
286
287 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
288 this.buildAvatarsJoin()
289
290 toSelect.push(this.tableAttributes.getAvatarAttributes())
291 }
292
293 this.select = this.buildSelect(toSelect)
294 }
295
296 private buildInnerListSelect () {
297 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
298
299 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
300 this.buildAccountJoin()
301 this.buildVideoJoin()
302
303 toSelect = toSelect.concat([
304 this.tableAttributes.getVideoAttributes(),
305 this.tableAttributes.getAccountAttributes(),
306 this.tableAttributes.getActorAttributes(),
307 this.tableAttributes.getServerAttributes()
308 ])
309 }
310
311 if (this.options.includeReplyCounters === true) {
312 this.buildTotalRepliesSelect()
313 this.buildAuthorTotalRepliesSelect()
314
315 toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
316 toSelect.push('"totalReplies"."count" AS "totalReplies"')
317 }
318
319 this.innerSelect = this.buildSelect(toSelect)
320 }
321
322 // ---------------------------------------------------------------------------
323
324 private getBlockWhere (commentTableName: string, channelTableName: string) {
325 const where: string[] = []
326
327 const blockerIdsString = createSafeIn(
328 this.sequelize,
329 this.options.blockerAccountIds,
330 [ `"${channelTableName}"."accountId"` ]
331 )
332
333 where.push(
334 `NOT EXISTS (` +
335 `SELECT 1 FROM "accountBlocklist" ` +
336 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
337 `AND "accountId" IN (${blockerIdsString})` +
338 `)`
339 )
340
341 where.push(
342 `NOT EXISTS (` +
343 `SELECT 1 FROM "account" ` +
344 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
345 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
346 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
347 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
348 `)`
349 )
350
351 return where
352 }
353
354 // ---------------------------------------------------------------------------
355
356 private buildTotalRepliesSelect () {
357 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
358
359 // Help the planner by providing videoId that should filter out many comments
360 this.replacements.videoId = this.options.videoId
361
362 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
363 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
364 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
365 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
366 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
367 `AND "deletedAt" IS NULL ` +
368 `AND ${blockWhereString} ` +
369 `) "totalReplies" ON TRUE `
370 }
371
372 private buildAuthorTotalRepliesSelect () {
373 // Help the planner by providing videoId that should filter out many comments
374 this.replacements.videoId = this.options.videoId
375
376 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
377 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
378 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
379 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
380 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
381 `) "totalRepliesFromVideoAuthor" ON TRUE `
382 }
383
384 private getOrder () {
385 if (!this.options.sort) return ''
386
387 const orders = getSort(this.options.sort)
388
389 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
390 }
391
392 private getInnerLimit () {
393 if (!this.options.count) return ''
394
395 this.replacements.limit = this.options.count
396 this.replacements.offset = this.options.start || 0
397
398 return `LIMIT :limit OFFSET :offset `
399 }
400}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644
index 000000000..87f8750c1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-table-attributes.ts
@@ -0,0 +1,43 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index f0ce69501..cbd57ad8c 100644
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -1,9 +1,9 @@
1import { Sequelize } from 'sequelize' 1import { Sequelize } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { createSafeIn } from '@server/models/utils'
4import { MUserAccountId } from '@server/types/models' 3import { MUserAccountId } from '@server/types/models'
5import { ActorImageType } from '@shared/models' 4import { ActorImageType } from '@shared/models'
6import { AbstractRunQuery } from '../../../../shared/abstract-run-query' 5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes' 7import { VideoTableAttributes } from './video-table-attributes'
8 8
9/** 9/**
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index 7c864bf27..62f1855c7 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -2,11 +2,12 @@ import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator' 2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc' 3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants' 4import { WEBSERVER } from '@server/initializers/constants'
5import { buildDirectionAndField, createSafeIn, parseRowCountResult } from '@server/models/utils' 5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models' 6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
7import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' 8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
8import { AbstractRunQuery } from '../../../shared/abstract-run-query' 10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
9import { forceNumber } from '@shared/core-utils'
10 11
11/** 12/**
12 * 13 *
@@ -665,7 +666,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
665 } 666 }
666 667
667 private buildOrder (value: string) { 668 private buildOrder (value: string) {
668 const { direction, field } = buildDirectionAndField(value) 669 const { direction, field } = buildSortDirectionAndField(value)
669 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) 670 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
670 671
671 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' 672 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index 653b9694b..cebde3755 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -4,7 +4,7 @@ import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils' 4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video' 8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag' 9import { VideoTagModel } from './video-tag'
10 10
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 1cd8224c0..9247d0e2b 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -5,7 +5,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, SortType, throwIfNotValid } from '../utils' 8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail' 9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video' 10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
@@ -57,7 +57,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
57 static listForApi (parameters: { 57 static listForApi (parameters: {
58 start: number 58 start: number
59 count: number 59 count: number
60 sort: SortType 60 sort: string
61 search?: string 61 search?: string
62 type?: VideoBlacklistType 62 type?: VideoBlacklistType
63 }) { 63 }) {
@@ -67,7 +67,7 @@ export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlack
67 return { 67 return {
68 offset: start, 68 offset: start,
69 limit: count, 69 limit: count,
70 order: getBlacklistSort(sort.sortModel, sort.sortValue) 70 order: getBlacklistSort(sort)
71 } 71 }
72 } 72 }
73 73
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 5fbcd6e3b..2eaa77407 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -23,7 +23,7 @@ import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/vid
23import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config' 24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' 25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' 26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video' 27import { VideoModel } from './video'
28 28
29export enum ScopeNames { 29export enum ScopeNames {
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 1a1b8c88d..2db4b523a 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -3,7 +3,7 @@ import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@se
3import { AttributesOnly } from '@shared/typescript-utils' 3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
6import { getSort } from '../utils' 6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video' 7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8 8
9enum ScopeNames { 9enum ScopeNames {
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
index 6e49cde10..a4cbf51f5 100644
--- a/server/models/video/video-channel-sync.ts
+++ b/server/models/video/video-channel-sync.ts
@@ -21,7 +21,7 @@ import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account' 22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user' 23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../utils' 24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel' 25import { VideoChannelModel } from './video-channel'
26 26
27@DefaultScope(() => ({ 27@DefaultScope(() => ({
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 132c8f021..b71f5a197 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -43,8 +43,14 @@ import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { ActorFollowModel } from '../actor/actor-follow' 43import { ActorFollowModel } from '../actor/actor-follow'
44import { ActorImageModel } from '../actor/actor-image' 44import { ActorImageModel } from '../actor/actor-image'
45import { ServerModel } from '../server/server' 45import { ServerModel } from '../server/server'
46import { setAsUpdated } from '../shared' 46import {
47import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 47 buildServerIdsFollowedBy,
48 buildTrigramSearchIndex,
49 createSimilarityAttribute,
50 getSort,
51 setAsUpdated,
52 throwIfNotValid
53} from '../shared'
48import { VideoModel } from './video' 54import { VideoModel } from './video'
49import { VideoPlaylistModel } from './video-playlist' 55import { VideoPlaylistModel } from './video-playlist'
50 56
@@ -831,6 +837,6 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
831 } 837 }
832 838
833 setAsUpdated (transaction?: Transaction) { 839 setAsUpdated (transaction?: Transaction) {
834 return setAsUpdated('videoChannel', this.id, transaction) 840 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
835 } 841 }
836} 842}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index af9614d30..ff5142809 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BelongsTo, 4 BelongsTo,
@@ -13,11 +13,9 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { exists } from '@server/helpers/custom-validators/misc'
17import { getServerActor } from '@server/models/application/application' 16import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { uniqify } from '@shared/core-utils' 18import { pick, uniqify } from '@shared/core-utils'
20import { VideoPrivacy } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
23import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
41} from '../../types/models/video' 39} from '../../types/models/video'
42import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
44import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' 42import { ActorModel } from '../actor/actor'
45import { 43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
46 buildBlockedAccountSQL, 44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
47 buildBlockedAccountSQLOptimized,
48 buildLocalAccountIdsIn,
49 getCommentSort,
50 searchAttribute,
51 throwIfNotValid
52} from '../utils'
53import { VideoModel } from './video' 45import { VideoModel } from './video'
54import { VideoChannelModel } from './video-channel' 46import { VideoChannelModel } from './video-channel'
55 47
56export enum ScopeNames { 48export enum ScopeNames {
57 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
58 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
59 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', 50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
60 WITH_VIDEO = 'WITH_VIDEO', 51 WITH_VIDEO = 'WITH_VIDEO'
61 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
62} 52}
63 53
64@Scopes(() => ({ 54@Scopes(() => ({
65 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
66 return {
67 attributes: {
68 include: [
69 [
70 Sequelize.literal(
71 '(' +
72 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
73 'SELECT COUNT("replies"."id") ' +
74 'FROM "videoComment" AS "replies" ' +
75 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
76 'AND "deletedAt" IS NULL ' +
77 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
78 ')'
79 ),
80 'totalReplies'
81 ],
82 [
83 Sequelize.literal(
84 '(' +
85 'SELECT COUNT("replies"."id") ' +
86 'FROM "videoComment" AS "replies" ' +
87 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
88 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
89 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
90 'AND "replies"."accountId" = "videoChannel"."accountId"' +
91 ')'
92 ),
93 'totalRepliesFromVideoAuthor'
94 ]
95 ]
96 }
97 } as FindOptions
98 },
99 [ScopeNames.WITH_ACCOUNT]: { 55 [ScopeNames.WITH_ACCOUNT]: {
100 include: [ 56 include: [
101 { 57 {
@@ -103,22 +59,6 @@ export enum ScopeNames {
103 } 59 }
104 ] 60 ]
105 }, 61 },
106 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
107 include: [
108 {
109 model: AccountModel.unscoped(),
110 include: [
111 {
112 attributes: {
113 exclude: unusedActorAttributesForAPI
114 },
115 model: ActorModel, // Default scope includes avatar and server
116 required: true
117 }
118 ]
119 }
120 ]
121 },
122 [ScopeNames.WITH_IN_REPLY_TO]: { 62 [ScopeNames.WITH_IN_REPLY_TO]: {
123 include: [ 63 include: [
124 { 64 {
@@ -252,6 +192,18 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
252 }) 192 })
253 CommentAbuses: VideoCommentAbuseModel[] 193 CommentAbuses: VideoCommentAbuseModel[]
254 194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
255 static loadById (id: number, t?: Transaction): Promise<MComment> { 207 static loadById (id: number, t?: Transaction): Promise<MComment> {
256 const query: FindOptions = { 208 const query: FindOptions = {
257 where: { 209 where: {
@@ -319,93 +271,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
319 searchAccount?: string 271 searchAccount?: string
320 searchVideo?: string 272 searchVideo?: string
321 }) { 273 }) {
322 const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters 274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
323 276
324 const where: WhereOptions = { 277 selectType: 'api',
325 deletedAt: null 278 notDeleted: true
326 }
327
328 const whereAccount: WhereOptions = {}
329 const whereActor: WhereOptions = {}
330 const whereVideo: WhereOptions = {}
331
332 if (isLocal === true) {
333 Object.assign(whereActor, {
334 serverId: null
335 })
336 } else if (isLocal === false) {
337 Object.assign(whereActor, {
338 serverId: {
339 [Op.ne]: null
340 }
341 })
342 }
343
344 if (search) {
345 Object.assign(where, {
346 [Op.or]: [
347 searchAttribute(search, 'text'),
348 searchAttribute(search, '$Account.Actor.preferredUsername$'),
349 searchAttribute(search, '$Account.name$'),
350 searchAttribute(search, '$Video.name$')
351 ]
352 })
353 }
354
355 if (searchAccount) {
356 Object.assign(whereActor, {
357 [Op.or]: [
358 searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
359 searchAttribute(searchAccount, '$Account.name$')
360 ]
361 })
362 }
363
364 if (searchVideo) {
365 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
366 }
367
368 if (exists(onLocalVideo)) {
369 Object.assign(whereVideo, { remote: !onLocalVideo })
370 }
371
372 const getQuery = (forCount: boolean) => {
373 return {
374 offset: start,
375 limit: count,
376 order: getCommentSort(sort),
377 where,
378 include: [
379 {
380 model: AccountModel.unscoped(),
381 required: true,
382 where: whereAccount,
383 include: [
384 {
385 attributes: {
386 exclude: unusedActorAttributesForAPI
387 },
388 model: forCount === true
389 ? ActorModel.unscoped() // Default scope includes avatar and server
390 : ActorModel,
391 required: true,
392 where: whereActor
393 }
394 ]
395 },
396 {
397 model: VideoModel.unscoped(),
398 required: true,
399 where: whereVideo
400 }
401 ]
402 }
403 } 279 }
404 280
405 return Promise.all([ 281 return Promise.all([
406 VideoCommentModel.count(getQuery(true)), 282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
407 VideoCommentModel.findAll(getQuery(false)) 283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
408 ]).then(([ total, data ]) => ({ total, data })) 284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
409 } 287 }
410 288
411 static async listThreadsForApi (parameters: { 289 static async listThreadsForApi (parameters: {
@@ -416,67 +294,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
416 sort: string 294 sort: string
417 user?: MUserAccountId 295 user?: MUserAccountId
418 }) { 296 }) {
419 const { videoId, isVideoOwned, start, count, sort, user } = parameters 297 const { videoId, user } = parameters
420 298
421 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
422 300
423 const accountBlockedWhere = { 301 const commonOptions: ListVideoCommentsOptions = {
424 accountId: { 302 selectType: 'api',
425 [Op.notIn]: Sequelize.literal( 303 videoId,
426 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' 304 blockerAccountIds
427 )
428 }
429 } 305 }
430 306
431 const queryList = { 307 const listOptions: ListVideoCommentsOptions = {
432 offset: start, 308 ...commonOptions,
433 limit: count, 309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
434 order: getCommentSort(sort), 310
435 where: { 311 isThread: true,
436 [Op.and]: [ 312 includeReplyCounters: true
437 {
438 videoId
439 },
440 {
441 inReplyToCommentId: null
442 },
443 {
444 [Op.or]: [
445 accountBlockedWhere,
446 {
447 accountId: null
448 }
449 ]
450 }
451 ]
452 }
453 } 313 }
454 314
455 const findScopesList: (string | ScopeOptions)[] = [ 315 const countOptions: ListVideoCommentsOptions = {
456 ScopeNames.WITH_ACCOUNT_FOR_API, 316 ...commonOptions,
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461 317
462 const countScopesList: ScopeOptions[] = [ 318 isThread: true
463 { 319 }
464 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
465 }
466 ]
467 320
468 const notDeletedQueryCount = { 321 const notDeletedCountOptions: ListVideoCommentsOptions = {
469 where: { 322 ...commonOptions,
470 videoId, 323
471 deletedAt: null, 324 notDeleted: true
472 ...accountBlockedWhere
473 }
474 } 325 }
475 326
476 return Promise.all([ 327 return Promise.all([
477 VideoCommentModel.scope(findScopesList).findAll(queryList), 328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
478 VideoCommentModel.scope(countScopesList).count(queryList), 329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
479 VideoCommentModel.count(notDeletedQueryCount) 330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
480 ]).then(([ rows, count, totalNotDeletedComments ]) => { 331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
481 return { total: count, data: rows, totalNotDeletedComments } 332 return { total: count, data: rows, totalNotDeletedComments }
482 }) 333 })
@@ -484,54 +335,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
484 335
485 static async listThreadCommentsForApi (parameters: { 336 static async listThreadCommentsForApi (parameters: {
486 videoId: number 337 videoId: number
487 isVideoOwned: boolean
488 threadId: number 338 threadId: number
489 user?: MUserAccountId 339 user?: MUserAccountId
490 }) { 340 }) {
491 const { videoId, threadId, user, isVideoOwned } = parameters 341 const { user } = parameters
492 342
493 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
494 344
495 const query = { 345 const queryOptions: ListVideoCommentsOptions = {
496 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, 346 ...pick(parameters, [ 'videoId', 'threadId' ]),
497 where: {
498 videoId,
499 [Op.and]: [
500 {
501 [Op.or]: [
502 { id: threadId },
503 { originCommentId: threadId }
504 ]
505 },
506 {
507 [Op.or]: [
508 {
509 accountId: {
510 [Op.notIn]: Sequelize.literal(
511 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
512 )
513 }
514 },
515 {
516 accountId: null
517 }
518 ]
519 }
520 ]
521 }
522 }
523 347
524 const scopes: any[] = [ 348 selectType: 'api',
525 ScopeNames.WITH_ACCOUNT_FOR_API, 349 sort: 'createdAt',
526 { 350
527 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 351 blockerAccountIds,
528 } 352 includeReplyCounters: true
529 ] 353 }
530 354
531 return Promise.all([ 355 return Promise.all([
532 VideoCommentModel.count(query), 356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
533 VideoCommentModel.scope(scopes).findAll(query) 357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
534 ]).then(([ total, data ]) => ({ total, data })) 358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
535 } 361 }
536 362
537 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +385,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
559 .findAll(query) 385 .findAll(query)
560 } 386 }
561 387
562 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { 388 static async listAndCountByVideoForAP (parameters: {
563 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ 389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
564 videoId: video.id, 401 videoId: video.id,
565 isVideoOwned: video.isOwned() 402 sort: 'createdAt',
566 })
567 403
568 const query = { 404 blockerAccountIds
569 order: [ [ 'createdAt', 'ASC' ] ] as Order,
570 offset: start,
571 limit: count,
572 where: {
573 videoId: video.id,
574 accountId: {
575 [Op.notIn]: Sequelize.literal(
576 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
577 )
578 }
579 },
580 transaction: t
581 } 405 }
582 406
583 return Promise.all([ 407 return Promise.all([
584 VideoCommentModel.count(query), 408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
585 VideoCommentModel.findAll<MComment>(query) 409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
586 ]).then(([ total, data ]) => ({ total, data })) 410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
587 } 413 }
588 414
589 static async listForFeed (parameters: { 415 static async listForFeed (parameters: {
@@ -592,97 +418,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
592 videoId?: number 418 videoId?: number
593 accountId?: number 419 accountId?: number
594 videoChannelId?: number 420 videoChannelId?: number
595 }): Promise<MCommentOwnerVideoFeed[]> { 421 }) {
596 const serverActor = await getServerActor() 422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
597 const { start, count, videoId, accountId, videoChannelId } = parameters
598
599 const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
600 '"VideoCommentModel"."accountId"',
601 [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
602 )
603 423
604 if (accountId) { 424 const queryOptions: ListVideoCommentsOptions = {
605 whereAnd.push({ 425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
606 accountId
607 })
608 }
609 426
610 const accountWhere = { 427 selectType: 'feed',
611 [Op.and]: whereAnd
612 }
613 428
614 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined 429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
615 432
616 const query = { 433 blockerAccountIds
617 order: [ [ 'createdAt', 'DESC' ] ] as Order,
618 offset: start,
619 limit: count,
620 where: {
621 deletedAt: null,
622 accountId: accountWhere
623 },
624 include: [
625 {
626 attributes: [ 'name', 'uuid' ],
627 model: VideoModel.unscoped(),
628 required: true,
629 where: {
630 privacy: VideoPrivacy.PUBLIC
631 },
632 include: [
633 {
634 attributes: [ 'accountId' ],
635 model: VideoChannelModel.unscoped(),
636 required: true,
637 where: videoChannelWhere
638 }
639 ]
640 }
641 ]
642 } 434 }
643 435
644 if (videoId) query.where['videoId'] = videoId 436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
645
646 return VideoCommentModel
647 .scope([ ScopeNames.WITH_ACCOUNT ])
648 .findAll(query)
649 } 437 }
650 438
651 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { 439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
652 const accountWhere = filter.onVideosOfAccount 440 const queryOptions: ListVideoCommentsOptions = {
653 ? { id: filter.onVideosOfAccount.id } 441 selectType: 'comment-only',
654 : {}
655 442
656 const query = { 443 accountId: ofAccount.id,
657 limit: 1000, 444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
658 where: { 445
659 deletedAt: null, 446 notDeleted: true,
660 accountId: ofAccount.id 447 count: 5000
661 },
662 include: [
663 {
664 model: VideoModel,
665 required: true,
666 include: [
667 {
668 model: VideoChannelModel,
669 required: true,
670 include: [
671 {
672 model: AccountModel,
673 required: true,
674 where: accountWhere
675 }
676 ]
677 }
678 ]
679 }
680 ]
681 } 448 }
682 449
683 return VideoCommentModel 450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
684 .scope([ ScopeNames.WITH_ACCOUNT ])
685 .findAll(query)
686 } 451 }
687 452
688 static async getStats () { 453 static async getStats () {
@@ -750,9 +515,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
750 } 515 }
751 516
752 isOwned () { 517 isOwned () {
753 if (!this.Account) { 518 if (!this.Account) return false
754 return false
755 }
756 519
757 return this.Account.isOwned() 520 return this.Account.isOwned()
758 } 521 }
@@ -906,22 +669,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
906 } 669 }
907 670
908 private static async buildBlockerAccountIds (options: { 671 private static async buildBlockerAccountIds (options: {
909 videoId: number 672 user: MUserAccountId
910 isVideoOwned: boolean 673 }): Promise<number[]> {
911 user?: MUserAccountId 674 const { user } = options
912 }) {
913 const { videoId, user, isVideoOwned } = options
914 675
915 const serverActor = await getServerActor() 676 const serverActor = await getServerActor()
916 const blockerAccountIds = [ serverActor.Account.id ] 677 const blockerAccountIds = [ serverActor.Account.id ]
917 678
918 if (user) blockerAccountIds.push(user.Account.id) 679 if (user) blockerAccountIds.push(user.Account.id)
919 680
920 if (isVideoOwned) {
921 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
922 if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
923 }
924
925 return blockerAccountIds 681 return blockerAccountIds
926 } 682 }
927} 683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 9c4e6d078..07bc13de1 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -21,6 +21,7 @@ import {
21import validator from 'validator' 21import validator from 'validator'
22import { logger } from '@server/helpers/logger' 22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video' 23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
24import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' 25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
25import { 26import {
26 getHLSPrivateFileUrl, 27 getHLSPrivateFileUrl,
@@ -50,11 +51,9 @@ import {
50} from '../../initializers/constants' 51} from '../../initializers/constants'
51import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' 52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
52import { VideoRedundancyModel } from '../redundancy/video-redundancy' 53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
53import { doesExist } from '../shared' 54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
54import { parseAggregateResult, throwIfNotValid } from '../utils'
55import { VideoModel } from './video' 55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57import { CONFIG } from '@server/initializers/config'
58 57
59export enum ScopeNames { 58export enum ScopeNames {
60 WITH_VIDEO = 'WITH_VIDEO', 59 WITH_VIDEO = 'WITH_VIDEO',
@@ -266,7 +265,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
266 static doesInfohashExist (infoHash: string) { 265 static doesInfohashExist (infoHash: string) {
267 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
268 267
269 return doesExist(query, { infoHash }) 268 return doesExist(this.sequelize, query, { infoHash })
270 } 269 }
271 270
272 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) { 271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -282,14 +281,14 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
282 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
283 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1' 282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1'
284 283
285 return doesExist(query, { filename }) 284 return doesExist(this.sequelize, query, { filename })
286 } 285 }
287 286
288 static async doesOwnedWebTorrentVideoFileExist (filename: string) { 287 static async doesOwnedWebTorrentVideoFileExist (filename: string) {
289 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + 288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
290 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
291 290
292 return doesExist(query, { filename }) 291 return doesExist(this.sequelize, query, { filename })
293 } 292 }
294 293
295 static loadByFilename (filename: string) { 294 static loadByFilename (filename: string) {
@@ -439,7 +438,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
439 if (!element) return videoFile.save({ transaction }) 438 if (!element) return videoFile.save({ transaction })
440 439
441 for (const k of Object.keys(videoFile.toJSON())) { 440 for (const k of Object.keys(videoFile.toJSON())) {
442 element[k] = videoFile[k] 441 element.set(k, videoFile[k])
443 } 442 }
444 443
445 return element.save({ transaction }) 444 return element.save({ transaction })
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index da6b92c7a..c040e0fda 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -22,7 +22,7 @@ import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../help
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user' 24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../utils' 25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync' 27import { VideoChannelSyncModel } from './video-channel-sync'
28 28
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index 7181b5599..b832f9768 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -31,7 +31,7 @@ import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account' 33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../utils' 34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist' 36import { VideoPlaylistModel } from './video-playlist'
37 37
@@ -309,7 +309,23 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
309 return VideoPlaylistElementModel.increment({ position: by }, query) 309 return VideoPlaylistElementModel.increment({ position: by }, query)
310 } 310 }
311 311
312 getType (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) { 312 toFormattedJSON (
313 this: MVideoPlaylistElementFormattable,
314 options: { accountId?: number } = {}
315 ): VideoPlaylistElement {
316 return {
317 id: this.id,
318 position: this.position,
319 startTimestamp: this.startTimestamp,
320 stopTimestamp: this.stopTimestamp,
321
322 type: this.getType(options.accountId),
323
324 video: this.getVideoElement(options.accountId)
325 }
326 }
327
328 getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
313 const video = this.Video 329 const video = this.Video
314 330
315 if (!video) return VideoPlaylistElementType.DELETED 331 if (!video) return VideoPlaylistElementType.DELETED
@@ -323,34 +339,17 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
323 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE 339 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE
324 340
325 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE 341 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
326 if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
327 342
328 return VideoPlaylistElementType.REGULAR 343 return VideoPlaylistElementType.REGULAR
329 } 344 }
330 345
331 getVideoElement (this: MVideoPlaylistElementFormattable, displayNSFW?: boolean, accountId?: number) { 346 getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
332 if (!this.Video) return null 347 if (!this.Video) return null
333 if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null 348 if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
334 349
335 return this.Video.toFormattedJSON() 350 return this.Video.toFormattedJSON()
336 } 351 }
337 352
338 toFormattedJSON (
339 this: MVideoPlaylistElementFormattable,
340 options: { displayNSFW?: boolean, accountId?: number } = {}
341 ): VideoPlaylistElement {
342 return {
343 id: this.id,
344 position: this.position,
345 startTimestamp: this.startTimestamp,
346 stopTimestamp: this.stopTimestamp,
347
348 type: this.getType(options.displayNSFW, options.accountId),
349
350 video: this.getVideoElement(options.displayNSFW, options.accountId)
351 }
352 }
353
354 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject { 353 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
355 const base: PlaylistElementObject = { 354 const base: PlaylistElementObject = {
356 id: this.url, 355 id: this.url,
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 8bbe54c49..faf4bea78 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -21,12 +21,8 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
21import { MAccountId, MChannelId } from '@server/types/models' 21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' 22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils' 23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
24import { AttributesOnly } from '@shared/typescript-utils' 25import { AttributesOnly } from '@shared/typescript-utils'
25import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
26import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
27import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
28import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
29import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
30import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
31import { 27import {
32 isVideoPlaylistDescriptionValid, 28 isVideoPlaylistDescriptionValid,
@@ -53,7 +49,6 @@ import {
53} from '../../types/models/video/video-playlist' 49} from '../../types/models/video/video-playlist'
54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
55import { ActorModel } from '../actor/actor' 51import { ActorModel } from '../actor/actor'
56import { setAsUpdated } from '../shared'
57import { 52import {
58 buildServerIdsFollowedBy, 53 buildServerIdsFollowedBy,
59 buildTrigramSearchIndex, 54 buildTrigramSearchIndex,
@@ -61,8 +56,9 @@ import {
61 createSimilarityAttribute, 56 createSimilarityAttribute,
62 getPlaylistSort, 57 getPlaylistSort,
63 isOutdated, 58 isOutdated,
59 setAsUpdated,
64 throwIfNotValid 60 throwIfNotValid
65} from '../utils' 61} from '../shared'
66import { ThumbnailModel } from './thumbnail' 62import { ThumbnailModel } from './thumbnail'
67import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
68import { VideoPlaylistElementModel } from './video-playlist-element' 64import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -641,7 +637,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
641 } 637 }
642 638
643 setAsRefreshed () { 639 setAsRefreshed () {
644 return setAsUpdated('videoPlaylist', this.id) 640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
645 } 641 }
646 642
647 setVideosLength (videosLength: number) { 643 setVideosLength (videosLength: number) {
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index f2190037e..b4de2b20f 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -7,7 +7,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' 7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor' 9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12 12
13enum ScopeNames { 13enum ScopeNames {
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 0386edf28..a85c79c9f 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -37,8 +37,7 @@ import {
37 WEBSERVER 37 WEBSERVER
38} from '../../initializers/constants' 38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy' 39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist } from '../shared' 40import { doesExist, throwIfNotValid } from '../shared'
41import { throwIfNotValid } from '../utils'
42import { VideoModel } from './video' 41import { VideoModel } from './video'
43 42
44@Table({ 43@Table({
@@ -138,7 +137,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
138 static doesInfohashExist (infoHash: string) { 137 static doesInfohashExist (infoHash: string) {
139 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' 138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
140 139
141 return doesExist(query, { infoHash }) 140 return doesExist(this.sequelize, query, { infoHash })
142 } 141 }
143 142
144 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) { 143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -237,7 +236,7 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
237 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` + 236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
238 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
239 238
240 return doesExist(query, { videoUUID }) 239 return doesExist(this.sequelize, query, { videoUUID })
241 } 240 }
242 241
243 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) { 242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 56cc45cfe..1a10d2da2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -32,7 +32,7 @@ import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFil
32import { VideoPathManager } from '@server/lib/video-path-manager' 32import { VideoPathManager } from '@server/lib/video-path-manager'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { getServerActor } from '@server/models/application/application' 34import { getServerActor } from '@server/models/application/application'
35import { ModelCache } from '@server/models/model-cache' 35import { ModelCache } from '@server/models/shared/model-cache'
36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' 36import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils' 37import { ffprobePromise, getAudioStream, hasAudioStream, uuidToShort } from '@shared/extra-utils'
38import { 38import {
@@ -103,10 +103,9 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy'
103import { ServerModel } from '../server/server' 103import { ServerModel } from '../server/server'
104import { TrackerModel } from '../server/tracker' 104import { TrackerModel } from '../server/tracker'
105import { VideoTrackerModel } from '../server/video-tracker' 105import { VideoTrackerModel } from '../server/video-tracker'
106import { setAsUpdated } from '../shared' 106import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
107import { UserModel } from '../user/user' 107import { UserModel } from '../user/user'
108import { UserVideoHistoryModel } from '../user/user-video-history' 108import { UserVideoHistoryModel } from '../user/user-video-history'
109import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
110import { VideoViewModel } from '../view/video-view' 109import { VideoViewModel } from '../view/video-view'
111import { 110import {
112 videoFilesModelToFormattedJSON, 111 videoFilesModelToFormattedJSON,
@@ -1871,7 +1870,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1871 } 1870 }
1872 1871
1873 setAsRefreshed (transaction?: Transaction) { 1872 setAsRefreshed (transaction?: Transaction) {
1874 return setAsUpdated('video', this.id, transaction) 1873 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1875 } 1874 }
1876 1875
1877 // --------------------------------------------------------------------------- 1876 // ---------------------------------------------------------------------------
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
index 9d0d89a59..274117e86 100644
--- a/server/models/view/local-video-viewer.ts
+++ b/server/models/view/local-video-viewer.ts
@@ -21,6 +21,10 @@ import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-se
21 indexes: [ 21 indexes: [
22 { 22 {
23 fields: [ 'videoId' ] 23 fields: [ 'videoId' ]
24 },
25 {
26 fields: [ 'url' ],
27 unique: true
24 } 28 }
25 ] 29 ]
26}) 30})
diff --git a/server/tests/api/activitypub/cleaner.ts b/server/tests/api/activitypub/cleaner.ts
index eb6779123..1c1495022 100644
--- a/server/tests/api/activitypub/cleaner.ts
+++ b/server/tests/api/activitypub/cleaner.ts
@@ -148,7 +148,7 @@ describe('Test AP cleaner', function () {
148 it('Should destroy server 3 internal shares and correctly clean them', async function () { 148 it('Should destroy server 3 internal shares and correctly clean them', async function () {
149 this.timeout(20000) 149 this.timeout(20000)
150 150
151 const preCount = await servers[0].sql.getCount('videoShare') 151 const preCount = await servers[0].sql.getVideoShareCount()
152 expect(preCount).to.equal(6) 152 expect(preCount).to.equal(6)
153 153
154 await servers[2].sql.deleteAll('videoShare') 154 await servers[2].sql.deleteAll('videoShare')
@@ -156,7 +156,7 @@ describe('Test AP cleaner', function () {
156 await waitJobs(servers) 156 await waitJobs(servers)
157 157
158 // Still 6 because we don't have remote shares on local videos 158 // Still 6 because we don't have remote shares on local videos
159 const postCount = await servers[0].sql.getCount('videoShare') 159 const postCount = await servers[0].sql.getVideoShareCount()
160 expect(postCount).to.equal(6) 160 expect(postCount).to.equal(6)
161 }) 161 })
162 162
@@ -185,7 +185,7 @@ describe('Test AP cleaner', function () {
185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') { 185 async function check (like: string, ofServerUrl: string, urlSuffix: string, remote: 'true' | 'false') {
186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` + 186 const query = `SELECT "videoId", "accountVideoRate".url FROM "accountVideoRate" ` +
187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'` 187 `INNER JOIN video ON "accountVideoRate"."videoId" = video.id AND remote IS ${remote} WHERE "accountVideoRate"."url" LIKE '${like}'`
188 const res = await servers[0].sql.selectQuery(query) 188 const res = await servers[0].sql.selectQuery<{ url: string }>(query)
189 189
190 for (const rate of res) { 190 for (const rate of res) {
191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`) 191 const matcher = new RegExp(`^${ofServerUrl}/accounts/root/dislikes/\\d+${urlSuffix}$`)
@@ -231,7 +231,7 @@ describe('Test AP cleaner', function () {
231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` + 231 const query = `SELECT "videoId", "videoComment".url, uuid as "videoUUID" FROM "videoComment" ` +
232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'` 232 `INNER JOIN video ON "videoComment"."videoId" = video.id AND remote IS ${remote} WHERE "videoComment"."url" LIKE '${like}'`
233 233
234 const res = await servers[0].sql.selectQuery(query) 234 const res = await servers[0].sql.selectQuery<{ url: string, videoUUID: string }>(query)
235 235
236 for (const comment of res) { 236 for (const comment of res) {
237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`) 237 const matcher = new RegExp(`${ofServerUrl}/videos/watch/${comment.videoUUID}/comments/\\d+${urlSuffix}`)
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 3415625ca..93a3f3eb9 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -79,6 +79,7 @@ describe('Test config API validators', function () {
79 signup: { 79 signup: {
80 enabled: false, 80 enabled: false,
81 limit: 5, 81 limit: 5,
82 requiresApproval: false,
82 requiresEmailVerification: false, 83 requiresEmailVerification: false,
83 minimumAge: 16 84 minimumAge: 16
84 }, 85 },
@@ -313,6 +314,7 @@ describe('Test config API validators', function () {
313 signup: { 314 signup: {
314 enabled: true, 315 enabled: true,
315 limit: 5, 316 limit: 5,
317 requiresApproval: true,
316 requiresEmailVerification: true 318 requiresEmailVerification: true
317 } 319 }
318 } 320 }
diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts
index 7968ef802..f0f8819b9 100644
--- a/server/tests/api/check-params/contact-form.ts
+++ b/server/tests/api/check-params/contact-form.ts
@@ -2,7 +2,14 @@
2 2
3import { MockSmtpServer } from '@server/tests/shared' 3import { MockSmtpServer } from '@server/tests/shared'
4import { HttpStatusCode } from '@shared/models' 4import { HttpStatusCode } from '@shared/models'
5import { cleanupTests, ContactFormCommand, createSingleServer, killallServers, PeerTubeServer } from '@shared/server-commands' 5import {
6 cleanupTests,
7 ConfigCommand,
8 ContactFormCommand,
9 createSingleServer,
10 killallServers,
11 PeerTubeServer
12} from '@shared/server-commands'
6 13
7describe('Test contact form API validators', function () { 14describe('Test contact form API validators', function () {
8 let server: PeerTubeServer 15 let server: PeerTubeServer
@@ -38,7 +45,7 @@ describe('Test contact form API validators', function () {
38 await killallServers([ server ]) 45 await killallServers([ server ])
39 46
40 // Contact form is disabled 47 // Contact form is disabled
41 await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort }, contact_form: { enabled: false } }) 48 await server.run({ ...ConfigCommand.getEmailOverrideConfig(emailPort), contact_form: { enabled: false } })
42 await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 }) 49 await command.send({ ...defaultBody, expectedStatus: HttpStatusCode.CONFLICT_409 })
43 }) 50 })
44 51
@@ -48,7 +55,7 @@ describe('Test contact form API validators', function () {
48 await killallServers([ server ]) 55 await killallServers([ server ])
49 56
50 // Email & contact form enabled 57 // Email & contact form enabled
51 await server.run({ smtp: { hostname: '127.0.0.1', port: emailPort } }) 58 await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
52 59
53 await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 60 await command.send({ ...defaultBody, fromEmail: 'badEmail', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
54 await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 61 await command.send({ ...defaultBody, fromEmail: 'badEmail@', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 961093bb5..ddbcb42f8 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -15,6 +15,7 @@ import './metrics'
15import './my-user' 15import './my-user'
16import './plugins' 16import './plugins'
17import './redundancy' 17import './redundancy'
18import './registrations'
18import './search' 19import './search'
19import './services' 20import './services'
20import './transcoding' 21import './transcoding'
@@ -23,7 +24,7 @@ import './upload-quota'
23import './user-notifications' 24import './user-notifications'
24import './user-subscriptions' 25import './user-subscriptions'
25import './users-admin' 26import './users-admin'
26import './users' 27import './users-emails'
27import './video-blacklist' 28import './video-blacklist'
28import './video-captions' 29import './video-captions'
29import './video-channel-syncs' 30import './video-channel-syncs'
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 908407b9a..73dfd489d 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -24,7 +24,7 @@ describe('Test server redundancy API validators', function () {
24 // --------------------------------------------------------------- 24 // ---------------------------------------------------------------
25 25
26 before(async function () { 26 before(async function () {
27 this.timeout(80000) 27 this.timeout(160000)
28 28
29 servers = await createMultipleServers(2) 29 servers = await createMultipleServers(2)
30 30
diff --git a/server/tests/api/check-params/registrations.ts b/server/tests/api/check-params/registrations.ts
new file mode 100644
index 000000000..fe16ebd93
--- /dev/null
+++ b/server/tests/api/check-params/registrations.ts
@@ -0,0 +1,433 @@
1import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
2import { omit } from '@shared/core-utils'
3import { HttpStatusCode, UserRole } from '@shared/models'
4import {
5 cleanupTests,
6 createSingleServer,
7 makePostBodyRequest,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 setDefaultAccountAvatar,
11 setDefaultChannelAvatar
12} from '@shared/server-commands'
13
14describe('Test registrations API validators', function () {
15 let server: PeerTubeServer
16 let userToken: string
17 let moderatorToken: string
18
19 // ---------------------------------------------------------------
20
21 before(async function () {
22 this.timeout(30000)
23
24 server = await createSingleServer(1)
25
26 await setAccessTokensToServers([ server ])
27 await setDefaultAccountAvatar([ server ])
28 await setDefaultChannelAvatar([ server ])
29
30 await server.config.enableSignup(false);
31
32 ({ token: moderatorToken } = await server.users.generate('moderator', UserRole.MODERATOR));
33 ({ token: userToken } = await server.users.generate('user', UserRole.USER))
34 })
35
36 describe('Register', function () {
37 const registrationPath = '/api/v1/users/register'
38 const registrationRequestPath = '/api/v1/users/registrations/request'
39
40 const baseCorrectParams = {
41 username: 'user3',
42 displayName: 'super user',
43 email: 'test3@example.com',
44 password: 'my super password',
45 registrationReason: 'my super registration reason'
46 }
47
48 describe('When registering a new user or requesting user registration', function () {
49
50 async function check (fields: any, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
51 await server.config.enableSignup(false)
52 await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus })
53
54 await server.config.enableSignup(true)
55 await makePostBodyRequest({ url: server.url, path: registrationRequestPath, fields, expectedStatus })
56 }
57
58 it('Should fail with a too small username', async function () {
59 const fields = { ...baseCorrectParams, username: '' }
60
61 await check(fields)
62 })
63
64 it('Should fail with a too long username', async function () {
65 const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
66
67 await check(fields)
68 })
69
70 it('Should fail with an incorrect username', async function () {
71 const fields = { ...baseCorrectParams, username: 'my username' }
72
73 await check(fields)
74 })
75
76 it('Should fail with a missing email', async function () {
77 const fields = omit(baseCorrectParams, [ 'email' ])
78
79 await check(fields)
80 })
81
82 it('Should fail with an invalid email', async function () {
83 const fields = { ...baseCorrectParams, email: 'test_example.com' }
84
85 await check(fields)
86 })
87
88 it('Should fail with a too small password', async function () {
89 const fields = { ...baseCorrectParams, password: 'bla' }
90
91 await check(fields)
92 })
93
94 it('Should fail with a too long password', async function () {
95 const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
96
97 await check(fields)
98 })
99
100 it('Should fail if we register a user with the same username', async function () {
101 const fields = { ...baseCorrectParams, username: 'root' }
102
103 await check(fields, HttpStatusCode.CONFLICT_409)
104 })
105
106 it('Should fail with a "peertube" username', async function () {
107 const fields = { ...baseCorrectParams, username: 'peertube' }
108
109 await check(fields, HttpStatusCode.CONFLICT_409)
110 })
111
112 it('Should fail if we register a user with the same email', async function () {
113 const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
114
115 await check(fields, HttpStatusCode.CONFLICT_409)
116 })
117
118 it('Should fail with a bad display name', async function () {
119 const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
120
121 await check(fields)
122 })
123
124 it('Should fail with a bad channel name', async function () {
125 const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
126
127 await check(fields)
128 })
129
130 it('Should fail with a bad channel display name', async function () {
131 const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
132
133 await check(fields)
134 })
135
136 it('Should fail with a channel name that is the same as username', async function () {
137 const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
138 const fields = { ...baseCorrectParams, ...source }
139
140 await check(fields)
141 })
142
143 it('Should fail with an existing channel', async function () {
144 const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
145 await server.channels.create({ attributes })
146
147 const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
148
149 await check(fields, HttpStatusCode.CONFLICT_409)
150 })
151
152 it('Should fail on a server with registration disabled', async function () {
153 this.timeout(60000)
154
155 await server.config.updateExistingSubConfig({
156 newConfig: {
157 signup: {
158 enabled: false
159 }
160 }
161 })
162
163 await server.registrations.register({ username: 'user4', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
164 await server.registrations.requestRegistration({
165 username: 'user4',
166 registrationReason: 'reason',
167 expectedStatus: HttpStatusCode.FORBIDDEN_403
168 })
169 })
170
171 it('Should fail if the user limit is reached', async function () {
172 this.timeout(60000)
173
174 const { total } = await server.users.list()
175
176 await server.config.enableSignup(false, total)
177 await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
178
179 await server.config.enableSignup(true, total)
180 await server.registrations.requestRegistration({
181 username: 'user42',
182 registrationReason: 'reason',
183 expectedStatus: HttpStatusCode.FORBIDDEN_403
184 })
185 })
186
187 it('Should succeed if the user limit is not reached', async function () {
188 this.timeout(60000)
189
190 const { total } = await server.users.list()
191
192 await server.config.enableSignup(false, total + 1)
193 await server.registrations.register({ username: 'user43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
194
195 await server.config.enableSignup(true, total + 2)
196 await server.registrations.requestRegistration({
197 username: 'user44',
198 registrationReason: 'reason',
199 expectedStatus: HttpStatusCode.OK_200
200 })
201 })
202 })
203
204 describe('On direct registration', function () {
205
206 it('Should succeed with the correct params', async function () {
207 await server.config.enableSignup(false)
208
209 const fields = {
210 username: 'user_direct_1',
211 displayName: 'super user direct 1',
212 email: 'user_direct_1@example.com',
213 password: 'my super password',
214 channel: { name: 'super_user_direct_1_channel', displayName: 'super user direct 1 channel' }
215 }
216
217 await makePostBodyRequest({ url: server.url, path: registrationPath, fields, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
218 })
219
220 it('Should fail if the instance requires approval', async function () {
221 this.timeout(60000)
222
223 await server.config.enableSignup(true)
224 await server.registrations.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
225 })
226 })
227
228 describe('On registration request', function () {
229
230 before(async function () {
231 this.timeout(60000)
232
233 await server.config.enableSignup(true)
234 })
235
236 it('Should fail with an invalid registration reason', async function () {
237 for (const registrationReason of [ '', 't', 't'.repeat(5000) ]) {
238 await server.registrations.requestRegistration({
239 username: 'user_request_1',
240 registrationReason,
241 expectedStatus: HttpStatusCode.BAD_REQUEST_400
242 })
243 }
244 })
245
246 it('Should succeed with the correct params', async function () {
247 await server.registrations.requestRegistration({
248 username: 'user_request_2',
249 registrationReason: 'tt',
250 channel: {
251 displayName: 'my user request 2 channel',
252 name: 'user_request_2_channel'
253 }
254 })
255 })
256
257 it('Should fail if the user is already awaiting registration approval', async function () {
258 await server.registrations.requestRegistration({
259 username: 'user_request_2',
260 registrationReason: 'tt',
261 channel: {
262 displayName: 'my user request 42 channel',
263 name: 'user_request_42_channel'
264 },
265 expectedStatus: HttpStatusCode.CONFLICT_409
266 })
267 })
268
269 it('Should fail if the channel is already awaiting registration approval', async function () {
270 await server.registrations.requestRegistration({
271 username: 'user42',
272 registrationReason: 'tt',
273 channel: {
274 displayName: 'my user request 2 channel',
275 name: 'user_request_2_channel'
276 },
277 expectedStatus: HttpStatusCode.CONFLICT_409
278 })
279 })
280
281 it('Should fail if the instance does not require approval', async function () {
282 this.timeout(60000)
283
284 await server.config.enableSignup(false)
285
286 await server.registrations.requestRegistration({
287 username: 'user42',
288 registrationReason: 'toto',
289 expectedStatus: HttpStatusCode.BAD_REQUEST_400
290 })
291 })
292 })
293 })
294
295 describe('Registrations accept/reject', function () {
296 let id1: number
297 let id2: number
298
299 before(async function () {
300 this.timeout(60000)
301
302 await server.config.enableSignup(true);
303
304 ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_2', registrationReason: 'toto' }));
305 ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_3', registrationReason: 'toto' }))
306 })
307
308 it('Should fail to accept/reject registration without token', async function () {
309 const options = { id: id1, moderationResponse: 'tt', token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }
310 await server.registrations.accept(options)
311 await server.registrations.reject(options)
312 })
313
314 it('Should fail to accept/reject registration with a non moderator user', async function () {
315 const options = { id: id1, moderationResponse: 'tt', token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }
316 await server.registrations.accept(options)
317 await server.registrations.reject(options)
318 })
319
320 it('Should fail to accept/reject registration with a bad registration id', async function () {
321 {
322 const options = { id: 't' as any, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
323 await server.registrations.accept(options)
324 await server.registrations.reject(options)
325 }
326
327 {
328 const options = { id: 42, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
329 await server.registrations.accept(options)
330 await server.registrations.reject(options)
331 }
332 })
333
334 it('Should fail to accept/reject registration with a bad moderation resposne', async function () {
335 for (const moderationResponse of [ '', 't', 't'.repeat(5000) ]) {
336 const options = { id: id1, moderationResponse, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
337 await server.registrations.accept(options)
338 await server.registrations.reject(options)
339 }
340 })
341
342 it('Should succeed to accept a registration', async function () {
343 await server.registrations.accept({ id: id1, moderationResponse: 'tt', token: moderatorToken })
344 })
345
346 it('Should succeed to reject a registration', async function () {
347 await server.registrations.reject({ id: id2, moderationResponse: 'tt', token: moderatorToken })
348 })
349
350 it('Should fail to accept/reject a registration that was already accepted/rejected', async function () {
351 for (const id of [ id1, id2 ]) {
352 const options = { id, moderationResponse: 'tt', token: moderatorToken, expectedStatus: HttpStatusCode.CONFLICT_409 }
353 await server.registrations.accept(options)
354 await server.registrations.reject(options)
355 }
356 })
357 })
358
359 describe('Registrations deletion', function () {
360 let id1: number
361 let id2: number
362 let id3: number
363
364 before(async function () {
365 ({ id: id1 } = await server.registrations.requestRegistration({ username: 'request_4', registrationReason: 'toto' }));
366 ({ id: id2 } = await server.registrations.requestRegistration({ username: 'request_5', registrationReason: 'toto' }));
367 ({ id: id3 } = await server.registrations.requestRegistration({ username: 'request_6', registrationReason: 'toto' }))
368
369 await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
370 await server.registrations.reject({ id: id3, moderationResponse: 'tt' })
371 })
372
373 it('Should fail to delete registration without token', async function () {
374 await server.registrations.delete({ id: id1, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
375 })
376
377 it('Should fail to delete registration with a non moderator user', async function () {
378 await server.registrations.delete({ id: id1, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
379 })
380
381 it('Should fail to delete registration with a bad registration id', async function () {
382 await server.registrations.delete({ id: 't' as any, token: moderatorToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
383 await server.registrations.delete({ id: 42, token: moderatorToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
384 })
385
386 it('Should succeed with the correct params', async function () {
387 await server.registrations.delete({ id: id1, token: moderatorToken })
388 await server.registrations.delete({ id: id2, token: moderatorToken })
389 await server.registrations.delete({ id: id3, token: moderatorToken })
390 })
391 })
392
393 describe('Listing registrations', function () {
394 const path = '/api/v1/users/registrations'
395
396 it('Should fail with a bad start pagination', async function () {
397 await checkBadStartPagination(server.url, path, server.accessToken)
398 })
399
400 it('Should fail with a bad count pagination', async function () {
401 await checkBadCountPagination(server.url, path, server.accessToken)
402 })
403
404 it('Should fail with an incorrect sort', async function () {
405 await checkBadSortPagination(server.url, path, server.accessToken)
406 })
407
408 it('Should fail with a non authenticated user', async function () {
409 await server.registrations.list({
410 token: null,
411 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
412 })
413 })
414
415 it('Should fail with a non admin user', async function () {
416 await server.registrations.list({
417 token: userToken,
418 expectedStatus: HttpStatusCode.FORBIDDEN_403
419 })
420 })
421
422 it('Should succeed with the correct params', async function () {
423 await server.registrations.list({
424 token: moderatorToken,
425 search: 'toto'
426 })
427 })
428 })
429
430 after(async function () {
431 await cleanupTests([ server ])
432 })
433})
diff --git a/server/tests/api/check-params/upload-quota.ts b/server/tests/api/check-params/upload-quota.ts
index 70e6f4af9..fdc711bd5 100644
--- a/server/tests/api/check-params/upload-quota.ts
+++ b/server/tests/api/check-params/upload-quota.ts
@@ -42,7 +42,7 @@ describe('Test upload quota', function () {
42 this.timeout(30000) 42 this.timeout(30000)
43 43
44 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } 44 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
45 await server.users.register(user) 45 await server.registrations.register(user)
46 const userToken = await server.login.getAccessToken(user) 46 const userToken = await server.login.getAccessToken(user)
47 47
48 const attributes = { fixture: 'video_short2.webm' } 48 const attributes = { fixture: 'video_short2.webm' }
@@ -57,7 +57,7 @@ describe('Test upload quota', function () {
57 this.timeout(30000) 57 this.timeout(30000)
58 58
59 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' } 59 const user = { username: 'registered' + randomInt(1, 1500), password: 'password' }
60 await server.users.register(user) 60 await server.registrations.register(user)
61 const userToken = await server.login.getAccessToken(user) 61 const userToken = await server.login.getAccessToken(user)
62 62
63 const attributes = { fixture: 'video_short2.webm' } 63 const attributes = { fixture: 'video_short2.webm' }
diff --git a/server/tests/api/check-params/users-admin.ts b/server/tests/api/check-params/users-admin.ts
index 7ba709c4a..be2496bb4 100644
--- a/server/tests/api/check-params/users-admin.ts
+++ b/server/tests/api/check-params/users-admin.ts
@@ -5,6 +5,7 @@ import { omit } from '@shared/core-utils'
5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 ConfigCommand,
8 createSingleServer, 9 createSingleServer,
9 killallServers, 10 killallServers,
10 makeGetRequest, 11 makeGetRequest,
@@ -156,13 +157,7 @@ describe('Test users admin API validators', function () {
156 157
157 await killallServers([ server ]) 158 await killallServers([ server ])
158 159
159 const config = { 160 await server.run(ConfigCommand.getEmailOverrideConfig(emailPort))
160 smtp: {
161 hostname: '127.0.0.1',
162 port: emailPort
163 }
164 }
165 await server.run(config)
166 161
167 const fields = { 162 const fields = {
168 ...baseCorrectParams, 163 ...baseCorrectParams,
diff --git a/server/tests/api/check-params/users-emails.ts b/server/tests/api/check-params/users-emails.ts
new file mode 100644
index 000000000..8cfb1d15f
--- /dev/null
+++ b/server/tests/api/check-params/users-emails.ts
@@ -0,0 +1,119 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import { MockSmtpServer } from '@server/tests/shared'
3import { HttpStatusCode, UserRole } from '@shared/models'
4import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
5
6describe('Test users API validators', function () {
7 let server: PeerTubeServer
8
9 // ---------------------------------------------------------------
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 ask_send_email: {
17 max: 10
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 await server.config.enableSignup(true)
24
25 await server.users.generate('moderator2', UserRole.MODERATOR)
26
27 await server.registrations.requestRegistration({
28 username: 'request1',
29 registrationReason: 'tt'
30 })
31 })
32
33 describe('When asking a password reset', function () {
34 const path = '/api/v1/users/ask-reset-password'
35
36 it('Should fail with a missing email', async function () {
37 const fields = {}
38
39 await makePostBodyRequest({ url: server.url, path, fields })
40 })
41
42 it('Should fail with an invalid email', async function () {
43 const fields = { email: 'hello' }
44
45 await makePostBodyRequest({ url: server.url, path, fields })
46 })
47
48 it('Should success with the correct params', async function () {
49 const fields = { email: 'admin@example.com' }
50
51 await makePostBodyRequest({
52 url: server.url,
53 path,
54 fields,
55 expectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 })
58 })
59
60 describe('When asking for an account verification email', function () {
61 const path = '/api/v1/users/ask-send-verify-email'
62
63 it('Should fail with a missing email', async function () {
64 const fields = {}
65
66 await makePostBodyRequest({ url: server.url, path, fields })
67 })
68
69 it('Should fail with an invalid email', async function () {
70 const fields = { email: 'hello' }
71
72 await makePostBodyRequest({ url: server.url, path, fields })
73 })
74
75 it('Should succeed with the correct params', async function () {
76 const fields = { email: 'admin@example.com' }
77
78 await makePostBodyRequest({
79 url: server.url,
80 path,
81 fields,
82 expectedStatus: HttpStatusCode.NO_CONTENT_204
83 })
84 })
85 })
86
87 describe('When asking for a registration verification email', function () {
88 const path = '/api/v1/users/registrations/ask-send-verify-email'
89
90 it('Should fail with a missing email', async function () {
91 const fields = {}
92
93 await makePostBodyRequest({ url: server.url, path, fields })
94 })
95
96 it('Should fail with an invalid email', async function () {
97 const fields = { email: 'hello' }
98
99 await makePostBodyRequest({ url: server.url, path, fields })
100 })
101
102 it('Should succeed with the correct params', async function () {
103 const fields = { email: 'request1@example.com' }
104
105 await makePostBodyRequest({
106 url: server.url,
107 path,
108 fields,
109 expectedStatus: HttpStatusCode.NO_CONTENT_204
110 })
111 })
112 })
113
114 after(async function () {
115 MockSmtpServer.Instance.kill()
116
117 await cleanupTests([ server ])
118 })
119})
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
deleted file mode 100644
index 7acfd8c2c..000000000
--- a/server/tests/api/check-params/users.ts
+++ /dev/null
@@ -1,255 +0,0 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import { MockSmtpServer } from '@server/tests/shared'
3import { omit } from '@shared/core-utils'
4import { HttpStatusCode, UserRole } from '@shared/models'
5import { cleanupTests, createSingleServer, makePostBodyRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
6
7describe('Test users API validators', function () {
8 const path = '/api/v1/users/'
9 let server: PeerTubeServer
10 let serverWithRegistrationDisabled: PeerTubeServer
11
12 // ---------------------------------------------------------------
13
14 before(async function () {
15 this.timeout(30000)
16
17 const res = await Promise.all([
18 createSingleServer(1, { signup: { limit: 3 } }),
19 createSingleServer(2)
20 ])
21
22 server = res[0]
23 serverWithRegistrationDisabled = res[1]
24
25 await setAccessTokensToServers([ server ])
26
27 await server.users.generate('moderator2', UserRole.MODERATOR)
28 })
29
30 describe('When registering a new user', function () {
31 const registrationPath = path + '/register'
32 const baseCorrectParams = {
33 username: 'user3',
34 displayName: 'super user',
35 email: 'test3@example.com',
36 password: 'my super password'
37 }
38
39 it('Should fail with a too small username', async function () {
40 const fields = { ...baseCorrectParams, username: '' }
41
42 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
43 })
44
45 it('Should fail with a too long username', async function () {
46 const fields = { ...baseCorrectParams, username: 'super'.repeat(50) }
47
48 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
49 })
50
51 it('Should fail with an incorrect username', async function () {
52 const fields = { ...baseCorrectParams, username: 'my username' }
53
54 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
55 })
56
57 it('Should fail with a missing email', async function () {
58 const fields = omit(baseCorrectParams, [ 'email' ])
59
60 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
61 })
62
63 it('Should fail with an invalid email', async function () {
64 const fields = { ...baseCorrectParams, email: 'test_example.com' }
65
66 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
67 })
68
69 it('Should fail with a too small password', async function () {
70 const fields = { ...baseCorrectParams, password: 'bla' }
71
72 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
73 })
74
75 it('Should fail with a too long password', async function () {
76 const fields = { ...baseCorrectParams, password: 'super'.repeat(61) }
77
78 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
79 })
80
81 it('Should fail if we register a user with the same username', async function () {
82 const fields = { ...baseCorrectParams, username: 'root' }
83
84 await makePostBodyRequest({
85 url: server.url,
86 path: registrationPath,
87 token: server.accessToken,
88 fields,
89 expectedStatus: HttpStatusCode.CONFLICT_409
90 })
91 })
92
93 it('Should fail with a "peertube" username', async function () {
94 const fields = { ...baseCorrectParams, username: 'peertube' }
95
96 await makePostBodyRequest({
97 url: server.url,
98 path: registrationPath,
99 token: server.accessToken,
100 fields,
101 expectedStatus: HttpStatusCode.CONFLICT_409
102 })
103 })
104
105 it('Should fail if we register a user with the same email', async function () {
106 const fields = { ...baseCorrectParams, email: 'admin' + server.internalServerNumber + '@example.com' }
107
108 await makePostBodyRequest({
109 url: server.url,
110 path: registrationPath,
111 token: server.accessToken,
112 fields,
113 expectedStatus: HttpStatusCode.CONFLICT_409
114 })
115 })
116
117 it('Should fail with a bad display name', async function () {
118 const fields = { ...baseCorrectParams, displayName: 'a'.repeat(150) }
119
120 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
121 })
122
123 it('Should fail with a bad channel name', async function () {
124 const fields = { ...baseCorrectParams, channel: { name: '[]azf', displayName: 'toto' } }
125
126 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
127 })
128
129 it('Should fail with a bad channel display name', async function () {
130 const fields = { ...baseCorrectParams, channel: { name: 'toto', displayName: '' } }
131
132 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
133 })
134
135 it('Should fail with a channel name that is the same as username', async function () {
136 const source = { username: 'super_user', channel: { name: 'super_user', displayName: 'display name' } }
137 const fields = { ...baseCorrectParams, ...source }
138
139 await makePostBodyRequest({ url: server.url, path: registrationPath, token: server.accessToken, fields })
140 })
141
142 it('Should fail with an existing channel', async function () {
143 const attributes = { name: 'existing_channel', displayName: 'hello', description: 'super description' }
144 await server.channels.create({ attributes })
145
146 const fields = { ...baseCorrectParams, channel: { name: 'existing_channel', displayName: 'toto' } }
147
148 await makePostBodyRequest({
149 url: server.url,
150 path: registrationPath,
151 token: server.accessToken,
152 fields,
153 expectedStatus: HttpStatusCode.CONFLICT_409
154 })
155 })
156
157 it('Should succeed with the correct params', async function () {
158 const fields = { ...baseCorrectParams, channel: { name: 'super_channel', displayName: 'toto' } }
159
160 await makePostBodyRequest({
161 url: server.url,
162 path: registrationPath,
163 token: server.accessToken,
164 fields,
165 expectedStatus: HttpStatusCode.NO_CONTENT_204
166 })
167 })
168
169 it('Should fail on a server with registration disabled', async function () {
170 const fields = {
171 username: 'user4',
172 email: 'test4@example.com',
173 password: 'my super password 4'
174 }
175
176 await makePostBodyRequest({
177 url: serverWithRegistrationDisabled.url,
178 path: registrationPath,
179 token: serverWithRegistrationDisabled.accessToken,
180 fields,
181 expectedStatus: HttpStatusCode.FORBIDDEN_403
182 })
183 })
184 })
185
186 describe('When registering multiple users on a server with users limit', function () {
187
188 it('Should fail when after 3 registrations', async function () {
189 await server.users.register({ username: 'user42', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
190 })
191
192 })
193
194 describe('When asking a password reset', function () {
195 const path = '/api/v1/users/ask-reset-password'
196
197 it('Should fail with a missing email', async function () {
198 const fields = {}
199
200 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
201 })
202
203 it('Should fail with an invalid email', async function () {
204 const fields = { email: 'hello' }
205
206 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
207 })
208
209 it('Should success with the correct params', async function () {
210 const fields = { email: 'admin@example.com' }
211
212 await makePostBodyRequest({
213 url: server.url,
214 path,
215 token: server.accessToken,
216 fields,
217 expectedStatus: HttpStatusCode.NO_CONTENT_204
218 })
219 })
220 })
221
222 describe('When asking for an account verification email', function () {
223 const path = '/api/v1/users/ask-send-verify-email'
224
225 it('Should fail with a missing email', async function () {
226 const fields = {}
227
228 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
229 })
230
231 it('Should fail with an invalid email', async function () {
232 const fields = { email: 'hello' }
233
234 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
235 })
236
237 it('Should succeed with the correct params', async function () {
238 const fields = { email: 'admin@example.com' }
239
240 await makePostBodyRequest({
241 url: server.url,
242 path,
243 token: server.accessToken,
244 fields,
245 expectedStatus: HttpStatusCode.NO_CONTENT_204
246 })
247 })
248 })
249
250 after(async function () {
251 MockSmtpServer.Instance.kill()
252
253 await cleanupTests([ server, serverWithRegistrationDisabled ])
254 })
255})
diff --git a/server/tests/api/live/live-fast-restream.ts b/server/tests/api/live/live-fast-restream.ts
index c0bb8d529..f6959b83c 100644
--- a/server/tests/api/live/live-fast-restream.ts
+++ b/server/tests/api/live/live-fast-restream.ts
@@ -78,9 +78,15 @@ describe('Fast restream in live', function () {
78 const video = await server.videos.get({ id: liveId }) 78 const video = await server.videos.get({ id: liveId })
79 expect(video.streamingPlaylists).to.have.lengthOf(1) 79 expect(video.streamingPlaylists).to.have.lengthOf(1)
80 80
81 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 }) 81 try {
82 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) 82 await server.live.getSegmentFile({ videoUUID: liveId, segment: 0, playlistNumber: 0 })
83 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) 83 await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 })
84 await makeRawRequest({ url: video.streamingPlaylists[0].segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 })
85 } catch (err) {
86 // FIXME: try to debug error in CI "Unexpected end of JSON input"
87 console.error(err)
88 throw err
89 }
84 90
85 await wait(100) 91 await wait(100)
86 } 92 }
@@ -129,7 +135,7 @@ describe('Fast restream in live', function () {
129 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) 135 await server.config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
130 }) 136 })
131 137
132 it('Should correctly fast reastream in a permanent live with and without save replay', async function () { 138 it('Should correctly fast restream in a permanent live with and without save replay', async function () {
133 this.timeout(480000) 139 this.timeout(480000)
134 140
135 // A test can take a long time, so prefer to run them in parallel 141 // A test can take a long time, so prefer to run them in parallel
diff --git a/server/tests/api/notifications/index.ts b/server/tests/api/notifications/index.ts
index 8caa30a3d..c0216b74f 100644
--- a/server/tests/api/notifications/index.ts
+++ b/server/tests/api/notifications/index.ts
@@ -2,4 +2,5 @@ import './admin-notifications'
2import './comments-notifications' 2import './comments-notifications'
3import './moderation-notifications' 3import './moderation-notifications'
4import './notifications-api' 4import './notifications-api'
5import './registrations-notifications'
5import './user-notifications' 6import './user-notifications'
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index b127a7a31..bb11a08aa 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -11,7 +11,6 @@ import {
11 checkNewInstanceFollower, 11 checkNewInstanceFollower,
12 checkNewVideoAbuseForModerators, 12 checkNewVideoAbuseForModerators,
13 checkNewVideoFromSubscription, 13 checkNewVideoFromSubscription,
14 checkUserRegistered,
15 checkVideoAutoBlacklistForModerators, 14 checkVideoAutoBlacklistForModerators,
16 checkVideoIsPublished, 15 checkVideoIsPublished,
17 MockInstancesIndex, 16 MockInstancesIndex,
@@ -34,7 +33,7 @@ describe('Test moderation notifications', function () {
34 let emails: object[] = [] 33 let emails: object[] = []
35 34
36 before(async function () { 35 before(async function () {
37 this.timeout(120000) 36 this.timeout(50000)
38 37
39 const res = await prepareNotificationsTest(3) 38 const res = await prepareNotificationsTest(3)
40 emails = res.emails 39 emails = res.emails
@@ -60,7 +59,7 @@ describe('Test moderation notifications', function () {
60 }) 59 })
61 60
62 it('Should not send a notification to moderators on local abuse reported by an admin', async function () { 61 it('Should not send a notification to moderators on local abuse reported by an admin', async function () {
63 this.timeout(20000) 62 this.timeout(50000)
64 63
65 const name = 'video for abuse ' + buildUUID() 64 const name = 'video for abuse ' + buildUUID()
66 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 65 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -72,7 +71,7 @@ describe('Test moderation notifications', function () {
72 }) 71 })
73 72
74 it('Should send a notification to moderators on local video abuse', async function () { 73 it('Should send a notification to moderators on local video abuse', async function () {
75 this.timeout(20000) 74 this.timeout(50000)
76 75
77 const name = 'video for abuse ' + buildUUID() 76 const name = 'video for abuse ' + buildUUID()
78 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 77 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -84,7 +83,7 @@ describe('Test moderation notifications', function () {
84 }) 83 })
85 84
86 it('Should send a notification to moderators on remote video abuse', async function () { 85 it('Should send a notification to moderators on remote video abuse', async function () {
87 this.timeout(20000) 86 this.timeout(50000)
88 87
89 const name = 'video for abuse ' + buildUUID() 88 const name = 'video for abuse ' + buildUUID()
90 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 89 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -99,7 +98,7 @@ describe('Test moderation notifications', function () {
99 }) 98 })
100 99
101 it('Should send a notification to moderators on local comment abuse', async function () { 100 it('Should send a notification to moderators on local comment abuse', async function () {
102 this.timeout(20000) 101 this.timeout(50000)
103 102
104 const name = 'video for abuse ' + buildUUID() 103 const name = 'video for abuse ' + buildUUID()
105 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 104 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -118,7 +117,7 @@ describe('Test moderation notifications', function () {
118 }) 117 })
119 118
120 it('Should send a notification to moderators on remote comment abuse', async function () { 119 it('Should send a notification to moderators on remote comment abuse', async function () {
121 this.timeout(20000) 120 this.timeout(50000)
122 121
123 const name = 'video for abuse ' + buildUUID() 122 const name = 'video for abuse ' + buildUUID()
124 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } }) 123 const video = await servers[0].videos.upload({ token: userToken1, attributes: { name } })
@@ -140,7 +139,7 @@ describe('Test moderation notifications', function () {
140 }) 139 })
141 140
142 it('Should send a notification to moderators on local account abuse', async function () { 141 it('Should send a notification to moderators on local account abuse', async function () {
143 this.timeout(20000) 142 this.timeout(50000)
144 143
145 const username = 'user' + new Date().getTime() 144 const username = 'user' + new Date().getTime()
146 const { account } = await servers[0].users.create({ username, password: 'donald' }) 145 const { account } = await servers[0].users.create({ username, password: 'donald' })
@@ -153,7 +152,7 @@ describe('Test moderation notifications', function () {
153 }) 152 })
154 153
155 it('Should send a notification to moderators on remote account abuse', async function () { 154 it('Should send a notification to moderators on remote account abuse', async function () {
156 this.timeout(20000) 155 this.timeout(50000)
157 156
158 const username = 'user' + new Date().getTime() 157 const username = 'user' + new Date().getTime()
159 const tmpToken = await servers[0].users.generateUserAndToken(username) 158 const tmpToken = await servers[0].users.generateUserAndToken(username)
@@ -327,32 +326,6 @@ describe('Test moderation notifications', function () {
327 }) 326 })
328 }) 327 })
329 328
330 describe('New registration', function () {
331 let baseParams: CheckerBaseParams
332
333 before(() => {
334 baseParams = {
335 server: servers[0],
336 emails,
337 socketNotifications: adminNotifications,
338 token: servers[0].accessToken
339 }
340 })
341
342 it('Should send a notification only to moderators when a user registers on the instance', async function () {
343 this.timeout(10000)
344
345 await servers[0].users.register({ username: 'user_45' })
346
347 await waitJobs(servers)
348
349 await checkUserRegistered({ ...baseParams, username: 'user_45', checkType: 'presence' })
350
351 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
352 await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_45', checkType: 'absence' })
353 })
354 })
355
356 describe('New instance follows', function () { 329 describe('New instance follows', function () {
357 const instanceIndexServer = new MockInstancesIndex() 330 const instanceIndexServer = new MockInstancesIndex()
358 let config: any 331 let config: any
@@ -512,10 +485,14 @@ describe('Test moderation notifications', function () {
512 }) 485 })
513 486
514 it('Should not send video publish notification if auto-blacklisted', async function () { 487 it('Should not send video publish notification if auto-blacklisted', async function () {
488 this.timeout(120000)
489
515 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' }) 490 await checkVideoIsPublished({ ...userBaseParams, videoName, shortUUID, checkType: 'absence' })
516 }) 491 })
517 492
518 it('Should not send a local user subscription notification if auto-blacklisted', async function () { 493 it('Should not send a local user subscription notification if auto-blacklisted', async function () {
494 this.timeout(120000)
495
519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' }) 496 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'absence' })
520 }) 497 })
521 498
@@ -524,7 +501,7 @@ describe('Test moderation notifications', function () {
524 }) 501 })
525 502
526 it('Should send video published and unblacklist after video unblacklisted', async function () { 503 it('Should send video published and unblacklist after video unblacklisted', async function () {
527 this.timeout(40000) 504 this.timeout(120000)
528 505
529 await servers[0].blacklist.remove({ videoId: uuid }) 506 await servers[0].blacklist.remove({ videoId: uuid })
530 507
@@ -537,10 +514,14 @@ describe('Test moderation notifications', function () {
537 }) 514 })
538 515
539 it('Should send a local user subscription notification after removed from blacklist', async function () { 516 it('Should send a local user subscription notification after removed from blacklist', async function () {
517 this.timeout(120000)
518
540 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' }) 519 await checkNewVideoFromSubscription({ ...adminBaseParamsServer1, videoName, shortUUID, checkType: 'presence' })
541 }) 520 })
542 521
543 it('Should send a remote user subscription notification after removed from blacklist', async function () { 522 it('Should send a remote user subscription notification after removed from blacklist', async function () {
523 this.timeout(120000)
524
544 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' }) 525 await checkNewVideoFromSubscription({ ...adminBaseParamsServer2, videoName, shortUUID, checkType: 'presence' })
545 }) 526 })
546 527
@@ -576,7 +557,7 @@ describe('Test moderation notifications', function () {
576 }) 557 })
577 558
578 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () { 559 it('Should not send publish/subscription notifications after scheduled update if video still auto-blacklisted', async function () {
579 this.timeout(40000) 560 this.timeout(120000)
580 561
581 // In 2 seconds 562 // In 2 seconds
582 const updateAt = new Date(new Date().getTime() + 2000) 563 const updateAt = new Date(new Date().getTime() + 2000)
diff --git a/server/tests/api/notifications/registrations-notifications.ts b/server/tests/api/notifications/registrations-notifications.ts
new file mode 100644
index 000000000..b5a7c2bb5
--- /dev/null
+++ b/server/tests/api/notifications/registrations-notifications.ts
@@ -0,0 +1,88 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import {
4 CheckerBaseParams,
5 checkRegistrationRequest,
6 checkUserRegistered,
7 MockSmtpServer,
8 prepareNotificationsTest
9} from '@server/tests/shared'
10import { UserNotification } from '@shared/models'
11import { cleanupTests, PeerTubeServer, waitJobs } from '@shared/server-commands'
12
13describe('Test registrations notifications', function () {
14 let server: PeerTubeServer
15 let userToken1: string
16
17 let userNotifications: UserNotification[] = []
18 let adminNotifications: UserNotification[] = []
19 let emails: object[] = []
20
21 let baseParams: CheckerBaseParams
22
23 before(async function () {
24 this.timeout(50000)
25
26 const res = await prepareNotificationsTest(1)
27
28 server = res.servers[0]
29 emails = res.emails
30 userToken1 = res.userAccessToken
31 adminNotifications = res.adminNotifications
32 userNotifications = res.userNotifications
33
34 baseParams = {
35 server,
36 emails,
37 socketNotifications: adminNotifications,
38 token: server.accessToken
39 }
40 })
41
42 describe('New direct registration for moderators', function () {
43
44 before(async function () {
45 await server.config.enableSignup(false)
46 })
47
48 it('Should send a notification only to moderators when a user registers on the instance', async function () {
49 this.timeout(50000)
50
51 await server.registrations.register({ username: 'user_10' })
52
53 await waitJobs([ server ])
54
55 await checkUserRegistered({ ...baseParams, username: 'user_10', checkType: 'presence' })
56
57 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
58 await checkUserRegistered({ ...baseParams, ...userOverride, username: 'user_10', checkType: 'absence' })
59 })
60 })
61
62 describe('New registration request for moderators', function () {
63
64 before(async function () {
65 await server.config.enableSignup(true)
66 })
67
68 it('Should send a notification on new registration request', async function () {
69 this.timeout(50000)
70
71 const registrationReason = 'my reason'
72 await server.registrations.requestRegistration({ username: 'user_11', registrationReason })
73
74 await waitJobs([ server ])
75
76 await checkRegistrationRequest({ ...baseParams, username: 'user_11', registrationReason, checkType: 'presence' })
77
78 const userOverride = { socketNotifications: userNotifications, token: userToken1, check: { web: true, mail: false } }
79 await checkRegistrationRequest({ ...baseParams, ...userOverride, username: 'user_11', registrationReason, checkType: 'absence' })
80 })
81 })
82
83 after(async function () {
84 MockSmtpServer.Instance.kill()
85
86 await cleanupTests([ server ])
87 })
88})
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index 71ad35a43..869d437d5 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -120,7 +120,7 @@ describe('Object storage for video static file privacy', function () {
120 // --------------------------------------------------------------------------- 120 // ---------------------------------------------------------------------------
121 121
122 it('Should upload a private video and have appropriate object storage ACL', async function () { 122 it('Should upload a private video and have appropriate object storage ACL', async function () {
123 this.timeout(60000) 123 this.timeout(120000)
124 124
125 { 125 {
126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 126 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -138,7 +138,7 @@ describe('Object storage for video static file privacy', function () {
138 }) 138 })
139 139
140 it('Should upload a public video and have appropriate object storage ACL', async function () { 140 it('Should upload a public video and have appropriate object storage ACL', async function () {
141 this.timeout(60000) 141 this.timeout(120000)
142 142
143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED }) 143 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.UNLISTED })
144 await waitJobs([ server ]) 144 await waitJobs([ server ])
diff --git a/server/tests/api/server/config-defaults.ts b/server/tests/api/server/config-defaults.ts
index 4fa37d0e2..d3b3a2447 100644
--- a/server/tests/api/server/config-defaults.ts
+++ b/server/tests/api/server/config-defaults.ts
@@ -149,7 +149,7 @@ describe('Test config defaults', function () {
149 }) 149 })
150 150
151 it('Should register a user with this default setting', async function () { 151 it('Should register a user with this default setting', async function () {
152 await server.users.register({ username: 'user_p2p_2' }) 152 await server.registrations.register({ username: 'user_p2p_2' })
153 153
154 const userToken = await server.login.getAccessToken('user_p2p_2') 154 const userToken = await server.login.getAccessToken('user_p2p_2')
155 155
@@ -194,7 +194,7 @@ describe('Test config defaults', function () {
194 }) 194 })
195 195
196 it('Should register a user with this default setting', async function () { 196 it('Should register a user with this default setting', async function () {
197 await server.users.register({ username: 'user_p2p_4' }) 197 await server.registrations.register({ username: 'user_p2p_4' })
198 198
199 const userToken = await server.login.getAccessToken('user_p2p_4') 199 const userToken = await server.login.getAccessToken('user_p2p_4')
200 200
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 22446fe0c..b91519660 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -50,6 +50,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
50 expect(data.signup.enabled).to.be.true 50 expect(data.signup.enabled).to.be.true
51 expect(data.signup.limit).to.equal(4) 51 expect(data.signup.limit).to.equal(4)
52 expect(data.signup.minimumAge).to.equal(16) 52 expect(data.signup.minimumAge).to.equal(16)
53 expect(data.signup.requiresApproval).to.be.false
53 expect(data.signup.requiresEmailVerification).to.be.false 54 expect(data.signup.requiresEmailVerification).to.be.false
54 55
55 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com') 56 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com')
@@ -152,6 +153,7 @@ function checkUpdatedConfig (data: CustomConfig) {
152 153
153 expect(data.signup.enabled).to.be.false 154 expect(data.signup.enabled).to.be.false
154 expect(data.signup.limit).to.equal(5) 155 expect(data.signup.limit).to.equal(5)
156 expect(data.signup.requiresApproval).to.be.false
155 expect(data.signup.requiresEmailVerification).to.be.false 157 expect(data.signup.requiresEmailVerification).to.be.false
156 expect(data.signup.minimumAge).to.equal(10) 158 expect(data.signup.minimumAge).to.equal(10)
157 159
@@ -285,6 +287,7 @@ const newCustomConfig: CustomConfig = {
285 signup: { 287 signup: {
286 enabled: false, 288 enabled: false,
287 limit: 5, 289 limit: 5,
290 requiresApproval: false,
288 requiresEmailVerification: false, 291 requiresEmailVerification: false,
289 minimumAge: 10 292 minimumAge: 10
290 }, 293 },
@@ -468,9 +471,9 @@ describe('Test config', function () {
468 this.timeout(5000) 471 this.timeout(5000)
469 472
470 await Promise.all([ 473 await Promise.all([
471 server.users.register({ username: 'user1' }), 474 server.registrations.register({ username: 'user1' }),
472 server.users.register({ username: 'user2' }), 475 server.registrations.register({ username: 'user2' }),
473 server.users.register({ username: 'user3' }) 476 server.registrations.register({ username: 'user3' })
474 ]) 477 ])
475 478
476 const data = await server.config.getConfig() 479 const data = await server.config.getConfig()
diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts
index 325218008..dd971203a 100644
--- a/server/tests/api/server/contact-form.ts
+++ b/server/tests/api/server/contact-form.ts
@@ -6,6 +6,7 @@ import { wait } from '@shared/core-utils'
6import { HttpStatusCode } from '@shared/models' 6import { HttpStatusCode } from '@shared/models'
7import { 7import {
8 cleanupTests, 8 cleanupTests,
9 ConfigCommand,
9 ContactFormCommand, 10 ContactFormCommand,
10 createSingleServer, 11 createSingleServer,
11 PeerTubeServer, 12 PeerTubeServer,
@@ -23,13 +24,7 @@ describe('Test contact form', function () {
23 24
24 const port = await MockSmtpServer.Instance.collectEmails(emails) 25 const port = await MockSmtpServer.Instance.collectEmails(emails)
25 26
26 const overrideConfig = { 27 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
27 smtp: {
28 hostname: '127.0.0.1',
29 port
30 }
31 }
32 server = await createSingleServer(1, overrideConfig)
33 await setAccessTokensToServers([ server ]) 28 await setAccessTokensToServers([ server ])
34 29
35 command = server.contactForm 30 command = server.contactForm
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 4ab5463fe..db7aa65bd 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -3,7 +3,14 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared' 4import { MockSmtpServer } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands' 6import {
7 cleanupTests,
8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
7 14
8describe('Test emails', function () { 15describe('Test emails', function () {
9 let server: PeerTubeServer 16 let server: PeerTubeServer
@@ -24,21 +31,15 @@ describe('Test emails', function () {
24 username: 'user_1', 31 username: 'user_1',
25 password: 'super_password' 32 password: 'super_password'
26 } 33 }
27 let emailPort: number
28 34
29 before(async function () { 35 before(async function () {
30 this.timeout(50000) 36 this.timeout(50000)
31 37
32 emailPort = await MockSmtpServer.Instance.collectEmails(emails) 38 const emailPort = await MockSmtpServer.Instance.collectEmails(emails)
39 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
33 40
34 const overrideConfig = {
35 smtp: {
36 hostname: '127.0.0.1',
37 port: emailPort
38 }
39 }
40 server = await createSingleServer(1, overrideConfig)
41 await setAccessTokensToServers([ server ]) 41 await setAccessTokensToServers([ server ])
42 await server.config.enableSignup(true)
42 43
43 { 44 {
44 const created = await server.users.create({ username: user.username, password: user.password }) 45 const created = await server.users.create({ username: user.username, password: user.password })
@@ -322,6 +323,62 @@ describe('Test emails', function () {
322 }) 323 })
323 }) 324 })
324 325
326 describe('When verifying a registration email', function () {
327 let registrationId: number
328 let registrationIdEmail: number
329
330 before(async function () {
331 const { id } = await server.registrations.requestRegistration({
332 username: 'request_1',
333 email: 'request_1@example.com',
334 registrationReason: 'tt'
335 })
336 registrationId = id
337 })
338
339 it('Should ask to send the verification email', async function () {
340 this.timeout(10000)
341
342 await server.registrations.askSendVerifyEmail({ email: 'request_1@example.com' })
343
344 await waitJobs(server)
345 expect(emails).to.have.lengthOf(9)
346
347 const email = emails[8]
348
349 expect(email['from'][0]['name']).equal('PeerTube')
350 expect(email['from'][0]['address']).equal('test-admin@127.0.0.1')
351 expect(email['to'][0]['address']).equal('request_1@example.com')
352 expect(email['subject']).contains('Verify')
353
354 const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
355 expect(verificationStringMatches).not.to.be.null
356
357 verificationString = verificationStringMatches[1]
358 expect(verificationString).to.not.be.undefined
359 expect(verificationString).to.have.length.above(2)
360
361 const registrationIdMatches = /registrationId=([0-9]+)/.exec(email['text'])
362 expect(registrationIdMatches).not.to.be.null
363
364 registrationIdEmail = parseInt(registrationIdMatches[1], 10)
365
366 expect(registrationId).to.equal(registrationIdEmail)
367 })
368
369 it('Should not verify the email with an invalid verification string', async function () {
370 await server.registrations.verifyEmail({
371 registrationId: registrationIdEmail,
372 verificationString: verificationString + 'b',
373 expectedStatus: HttpStatusCode.FORBIDDEN_403
374 })
375 })
376
377 it('Should verify the email', async function () {
378 await server.registrations.verifyEmail({ registrationId: registrationIdEmail, verificationString })
379 })
380 })
381
325 after(async function () { 382 after(async function () {
326 MockSmtpServer.Instance.kill() 383 MockSmtpServer.Instance.kill()
327 384
diff --git a/server/tests/api/server/reverse-proxy.ts b/server/tests/api/server/reverse-proxy.ts
index d882f0bde..11c96c4b5 100644
--- a/server/tests/api/server/reverse-proxy.ts
+++ b/server/tests/api/server/reverse-proxy.ts
@@ -106,13 +106,13 @@ describe('Test application behind a reverse proxy', function () {
106 it('Should rate limit signup', async function () { 106 it('Should rate limit signup', async function () {
107 for (let i = 0; i < 10; i++) { 107 for (let i = 0; i < 10; i++) {
108 try { 108 try {
109 await server.users.register({ username: 'test' + i }) 109 await server.registrations.register({ username: 'test' + i })
110 } catch { 110 } catch {
111 // empty 111 // empty
112 } 112 }
113 } 113 }
114 114
115 await server.users.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 }) 115 await server.registrations.register({ username: 'test42', expectedStatus: HttpStatusCode.TOO_MANY_REQUESTS_429 })
116 }) 116 })
117 117
118 it('Should not rate limit failed signup', async function () { 118 it('Should not rate limit failed signup', async function () {
@@ -121,10 +121,10 @@ describe('Test application behind a reverse proxy', function () {
121 await wait(7000) 121 await wait(7000)
122 122
123 for (let i = 0; i < 3; i++) { 123 for (let i = 0; i < 3; i++) {
124 await server.users.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 }) 124 await server.registrations.register({ username: 'test' + i, expectedStatus: HttpStatusCode.CONFLICT_409 })
125 } 125 }
126 126
127 await server.users.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 }) 127 await server.registrations.register({ username: 'test43', expectedStatus: HttpStatusCode.NO_CONTENT_204 })
128 128
129 }) 129 })
130 130
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 643f1a531..a4443a8ec 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,6 +1,8 @@
1import './oauth'
2import './registrations`'
1import './two-factor' 3import './two-factor'
2import './user-subscriptions' 4import './user-subscriptions'
3import './user-videos' 5import './user-videos'
4import './users' 6import './users'
5import './users-multiple-servers' 7import './users-multiple-servers'
6import './users-verification' 8import './users-email-verification'
diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts
new file mode 100644
index 000000000..6a3da5ea2
--- /dev/null
+++ b/server/tests/api/users/oauth.ts
@@ -0,0 +1,192 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
6import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7
8describe('Test oauth', function () {
9 let server: PeerTubeServer
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 login: {
17 max: 30
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 })
24
25 describe('OAuth client', function () {
26
27 function expectInvalidClient (body: PeerTubeProblemDocument) {
28 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
29 expect(body.error).to.contain('client is invalid')
30 expect(body.type.startsWith('https://')).to.be.true
31 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
32 }
33
34 it('Should create a new client')
35
36 it('Should return the first client')
37
38 it('Should remove the last client')
39
40 it('Should not login with an invalid client id', async function () {
41 const client = { id: 'client', secret: server.store.client.secret }
42 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43
44 expectInvalidClient(body)
45 })
46
47 it('Should not login with an invalid client secret', async function () {
48 const client = { id: server.store.client.id, secret: 'coucou' }
49 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
50
51 expectInvalidClient(body)
52 })
53 })
54
55 describe('Login', function () {
56
57 function expectInvalidCredentials (body: PeerTubeProblemDocument) {
58 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
59 expect(body.error).to.contain('credentials are invalid')
60 expect(body.type.startsWith('https://')).to.be.true
61 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
62 }
63
64 it('Should not login with an invalid username', async function () {
65 const user = { username: 'captain crochet', password: server.store.user.password }
66 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
67
68 expectInvalidCredentials(body)
69 })
70
71 it('Should not login with an invalid password', async function () {
72 const user = { username: server.store.user.username, password: 'mew_three' }
73 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
74
75 expectInvalidCredentials(body)
76 })
77
78 it('Should be able to login', async function () {
79 await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
80 })
81
82 it('Should be able to login with an insensitive username', async function () {
83 const user = { username: 'RoOt', password: server.store.user.password }
84 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
85
86 const user2 = { username: 'rOoT', password: server.store.user.password }
87 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
88
89 const user3 = { username: 'ROOt', password: server.store.user.password }
90 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
91 })
92 })
93
94 describe('Logout', function () {
95
96 it('Should logout (revoke token)', async function () {
97 await server.login.logout({ token: server.accessToken })
98 })
99
100 it('Should not be able to get the user information', async function () {
101 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
102 })
103
104 it('Should not be able to upload a video', async function () {
105 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
106 })
107
108 it('Should be able to login again', async function () {
109 const body = await server.login.login()
110 server.accessToken = body.access_token
111 server.refreshToken = body.refresh_token
112 })
113
114 it('Should be able to get my user information again', async function () {
115 await server.users.getMyInfo()
116 })
117
118 it('Should have an expired access token', async function () {
119 this.timeout(60000)
120
121 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
122 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
123
124 await killallServers([ server ])
125 await server.run()
126
127 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
128 })
129
130 it('Should not be able to refresh an access token with an expired refresh token', async function () {
131 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
132 })
133
134 it('Should refresh the token', async function () {
135 this.timeout(50000)
136
137 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
138 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
139
140 await killallServers([ server ])
141 await server.run()
142
143 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
144 server.accessToken = res.body.access_token
145 server.refreshToken = res.body.refresh_token
146 })
147
148 it('Should be able to get my user information again', async function () {
149 await server.users.getMyInfo()
150 })
151 })
152
153 describe('Custom token lifetime', function () {
154 before(async function () {
155 this.timeout(120_000)
156
157 await server.kill()
158 await server.run({
159 oauth2: {
160 token_lifetime: {
161 access_token: '2 seconds',
162 refresh_token: '2 seconds'
163 }
164 }
165 })
166 })
167
168 it('Should have a very short access token lifetime', async function () {
169 this.timeout(50000)
170
171 const { access_token: accessToken } = await server.login.login()
172 await server.users.getMyInfo({ token: accessToken })
173
174 await wait(3000)
175 await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
176 })
177
178 it('Should have a very short refresh token lifetime', async function () {
179 this.timeout(50000)
180
181 const { refresh_token: refreshToken } = await server.login.login()
182 await server.login.refreshToken({ refreshToken })
183
184 await wait(3000)
185 await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
186 })
187 })
188
189 after(async function () {
190 await cleanupTests([ server ])
191 })
192})
diff --git a/server/tests/api/users/registrations.ts b/server/tests/api/users/registrations.ts
new file mode 100644
index 000000000..e6524f07d
--- /dev/null
+++ b/server/tests/api/users/registrations.ts
@@ -0,0 +1,415 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared'
5import { UserRegistrationState, UserRole } from '@shared/models'
6import {
7 cleanupTests,
8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
14
15describe('Test registrations', function () {
16 let server: PeerTubeServer
17
18 const emails: object[] = []
19 let emailPort: number
20
21 before(async function () {
22 this.timeout(30000)
23
24 emailPort = await MockSmtpServer.Instance.collectEmails(emails)
25
26 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(emailPort))
27
28 await setAccessTokensToServers([ server ])
29 await server.config.enableSignup(false)
30 })
31
32 describe('Direct registrations of a new user', function () {
33 let user1Token: string
34
35 it('Should register a new user', async function () {
36 const user = { displayName: 'super user 1', username: 'user_1', password: 'my super password' }
37 const channel = { name: 'my_user_1_channel', displayName: 'my channel rocks' }
38
39 await server.registrations.register({ ...user, channel })
40 })
41
42 it('Should be able to login with this registered user', async function () {
43 const user1 = { username: 'user_1', password: 'my super password' }
44
45 user1Token = await server.login.getAccessToken(user1)
46 })
47
48 it('Should have the correct display name', async function () {
49 const user = await server.users.getMyInfo({ token: user1Token })
50 expect(user.account.displayName).to.equal('super user 1')
51 })
52
53 it('Should have the correct video quota', async function () {
54 const user = await server.users.getMyInfo({ token: user1Token })
55 expect(user.videoQuota).to.equal(5 * 1024 * 1024)
56 })
57
58 it('Should have created the channel', async function () {
59 const { displayName } = await server.channels.get({ channelName: 'my_user_1_channel' })
60
61 expect(displayName).to.equal('my channel rocks')
62 })
63
64 it('Should remove me', async function () {
65 {
66 const { data } = await server.users.list()
67 expect(data.find(u => u.username === 'user_1')).to.not.be.undefined
68 }
69
70 await server.users.deleteMe({ token: user1Token })
71
72 {
73 const { data } = await server.users.list()
74 expect(data.find(u => u.username === 'user_1')).to.be.undefined
75 }
76 })
77 })
78
79 describe('Registration requests', function () {
80 let id2: number
81 let id3: number
82 let id4: number
83
84 let user2Token: string
85 let user3Token: string
86
87 before(async function () {
88 this.timeout(60000)
89
90 await server.config.enableSignup(true)
91
92 {
93 const { id } = await server.registrations.requestRegistration({
94 username: 'user4',
95 registrationReason: 'registration reason 4'
96 })
97
98 id4 = id
99 }
100 })
101
102 it('Should request a registration without a channel', async function () {
103 {
104 const { id } = await server.registrations.requestRegistration({
105 username: 'user2',
106 displayName: 'my super user 2',
107 email: 'user2@example.com',
108 password: 'user2password',
109 registrationReason: 'registration reason 2'
110 })
111
112 id2 = id
113 }
114 })
115
116 it('Should request a registration with a channel', async function () {
117 const { id } = await server.registrations.requestRegistration({
118 username: 'user3',
119 displayName: 'my super user 3',
120 channel: {
121 displayName: 'my user 3 channel',
122 name: 'super_user3_channel'
123 },
124 email: 'user3@example.com',
125 password: 'user3password',
126 registrationReason: 'registration reason 3'
127 })
128
129 id3 = id
130 })
131
132 it('Should list these registration requests', async function () {
133 {
134 const { total, data } = await server.registrations.list({ sort: '-createdAt' })
135 expect(total).to.equal(3)
136 expect(data).to.have.lengthOf(3)
137
138 {
139 expect(data[0].id).to.equal(id3)
140 expect(data[0].username).to.equal('user3')
141 expect(data[0].accountDisplayName).to.equal('my super user 3')
142
143 expect(data[0].channelDisplayName).to.equal('my user 3 channel')
144 expect(data[0].channelHandle).to.equal('super_user3_channel')
145
146 expect(data[0].createdAt).to.exist
147 expect(data[0].updatedAt).to.exist
148
149 expect(data[0].email).to.equal('user3@example.com')
150 expect(data[0].emailVerified).to.be.null
151
152 expect(data[0].moderationResponse).to.be.null
153 expect(data[0].registrationReason).to.equal('registration reason 3')
154 expect(data[0].state.id).to.equal(UserRegistrationState.PENDING)
155 expect(data[0].state.label).to.equal('Pending')
156 expect(data[0].user).to.be.null
157 }
158
159 {
160 expect(data[1].id).to.equal(id2)
161 expect(data[1].username).to.equal('user2')
162 expect(data[1].accountDisplayName).to.equal('my super user 2')
163
164 expect(data[1].channelDisplayName).to.be.null
165 expect(data[1].channelHandle).to.be.null
166
167 expect(data[1].createdAt).to.exist
168 expect(data[1].updatedAt).to.exist
169
170 expect(data[1].email).to.equal('user2@example.com')
171 expect(data[1].emailVerified).to.be.null
172
173 expect(data[1].moderationResponse).to.be.null
174 expect(data[1].registrationReason).to.equal('registration reason 2')
175 expect(data[1].state.id).to.equal(UserRegistrationState.PENDING)
176 expect(data[1].state.label).to.equal('Pending')
177 expect(data[1].user).to.be.null
178 }
179
180 {
181 expect(data[2].username).to.equal('user4')
182 }
183 }
184
185 {
186 const { total, data } = await server.registrations.list({ count: 1, start: 1, sort: 'createdAt' })
187
188 expect(total).to.equal(3)
189 expect(data).to.have.lengthOf(1)
190 expect(data[0].id).to.equal(id2)
191 }
192
193 {
194 const { total, data } = await server.registrations.list({ search: 'user3' })
195 expect(total).to.equal(1)
196 expect(data).to.have.lengthOf(1)
197 expect(data[0].id).to.equal(id3)
198 }
199 })
200
201 it('Should reject a registration request', async function () {
202 await server.registrations.reject({ id: id4, moderationResponse: 'I do not want id 4 on this instance' })
203 })
204
205 it('Should have sent an email to the user explanining the registration has been rejected', async function () {
206 this.timeout(50000)
207
208 await waitJobs([ server ])
209
210 const email = emails.find(e => e['to'][0]['address'] === 'user4@example.com')
211 expect(email).to.exist
212
213 expect(email['subject']).to.contain('been rejected')
214 expect(email['text']).to.contain('been rejected')
215 expect(email['text']).to.contain('I do not want id 4 on this instance')
216 })
217
218 it('Should accept registration requests', async function () {
219 await server.registrations.accept({ id: id2, moderationResponse: 'Welcome id 2' })
220 await server.registrations.accept({ id: id3, moderationResponse: 'Welcome id 3' })
221 })
222
223 it('Should have sent an email to the user explanining the registration has been accepted', async function () {
224 this.timeout(50000)
225
226 await waitJobs([ server ])
227
228 {
229 const email = emails.find(e => e['to'][0]['address'] === 'user2@example.com')
230 expect(email).to.exist
231
232 expect(email['subject']).to.contain('been accepted')
233 expect(email['text']).to.contain('been accepted')
234 expect(email['text']).to.contain('Welcome id 2')
235 }
236
237 {
238 const email = emails.find(e => e['to'][0]['address'] === 'user3@example.com')
239 expect(email).to.exist
240
241 expect(email['subject']).to.contain('been accepted')
242 expect(email['text']).to.contain('been accepted')
243 expect(email['text']).to.contain('Welcome id 3')
244 }
245 })
246
247 it('Should login with these users', async function () {
248 user2Token = await server.login.getAccessToken({ username: 'user2', password: 'user2password' })
249 user3Token = await server.login.getAccessToken({ username: 'user3', password: 'user3password' })
250 })
251
252 it('Should have created the appropriate attributes for user 2', async function () {
253 const me = await server.users.getMyInfo({ token: user2Token })
254
255 expect(me.username).to.equal('user2')
256 expect(me.account.displayName).to.equal('my super user 2')
257 expect(me.videoQuota).to.equal(5 * 1024 * 1024)
258 expect(me.videoChannels[0].name).to.equal('user2_channel')
259 expect(me.videoChannels[0].displayName).to.equal('Main user2 channel')
260 expect(me.role.id).to.equal(UserRole.USER)
261 expect(me.email).to.equal('user2@example.com')
262 })
263
264 it('Should have created the appropriate attributes for user 3', async function () {
265 const me = await server.users.getMyInfo({ token: user3Token })
266
267 expect(me.username).to.equal('user3')
268 expect(me.account.displayName).to.equal('my super user 3')
269 expect(me.videoQuota).to.equal(5 * 1024 * 1024)
270 expect(me.videoChannels[0].name).to.equal('super_user3_channel')
271 expect(me.videoChannels[0].displayName).to.equal('my user 3 channel')
272 expect(me.role.id).to.equal(UserRole.USER)
273 expect(me.email).to.equal('user3@example.com')
274 })
275
276 it('Should list these accepted/rejected registration requests', async function () {
277 const { data } = await server.registrations.list({ sort: 'createdAt' })
278 const { data: users } = await server.users.list()
279
280 {
281 expect(data[0].id).to.equal(id4)
282 expect(data[0].state.id).to.equal(UserRegistrationState.REJECTED)
283 expect(data[0].state.label).to.equal('Rejected')
284
285 expect(data[0].moderationResponse).to.equal('I do not want id 4 on this instance')
286 expect(data[0].user).to.be.null
287
288 expect(users.find(u => u.username === 'user4')).to.not.exist
289 }
290
291 {
292 expect(data[1].id).to.equal(id2)
293 expect(data[1].state.id).to.equal(UserRegistrationState.ACCEPTED)
294 expect(data[1].state.label).to.equal('Accepted')
295
296 expect(data[1].moderationResponse).to.equal('Welcome id 2')
297 expect(data[1].user).to.exist
298
299 const user2 = users.find(u => u.username === 'user2')
300 expect(data[1].user.id).to.equal(user2.id)
301 }
302
303 {
304 expect(data[2].id).to.equal(id3)
305 expect(data[2].state.id).to.equal(UserRegistrationState.ACCEPTED)
306 expect(data[2].state.label).to.equal('Accepted')
307
308 expect(data[2].moderationResponse).to.equal('Welcome id 3')
309 expect(data[2].user).to.exist
310
311 const user3 = users.find(u => u.username === 'user3')
312 expect(data[2].user.id).to.equal(user3.id)
313 }
314 })
315
316 it('Shoulde delete a registration', async function () {
317 await server.registrations.delete({ id: id2 })
318 await server.registrations.delete({ id: id3 })
319
320 const { total, data } = await server.registrations.list()
321 expect(total).to.equal(1)
322 expect(data).to.have.lengthOf(1)
323 expect(data[0].id).to.equal(id4)
324
325 const { data: users } = await server.users.list()
326
327 for (const username of [ 'user2', 'user3' ]) {
328 expect(users.find(u => u.username === username)).to.exist
329 }
330 })
331
332 it('Should be able to prevent email delivery on accept/reject', async function () {
333 this.timeout(50000)
334
335 let id1: number
336 let id2: number
337
338 {
339 const { id } = await server.registrations.requestRegistration({
340 username: 'user7',
341 email: 'user7@example.com',
342 registrationReason: 'tt'
343 })
344 id1 = id
345 }
346 {
347 const { id } = await server.registrations.requestRegistration({
348 username: 'user8',
349 email: 'user8@example.com',
350 registrationReason: 'tt'
351 })
352 id2 = id
353 }
354
355 await server.registrations.accept({ id: id1, moderationResponse: 'tt', preventEmailDelivery: true })
356 await server.registrations.reject({ id: id2, moderationResponse: 'tt', preventEmailDelivery: true })
357
358 await waitJobs([ server ])
359
360 const filtered = emails.filter(e => {
361 const address = e['to'][0]['address']
362 return address === 'user7@example.com' || address === 'user8@example.com'
363 })
364
365 expect(filtered).to.have.lengthOf(0)
366 })
367
368 it('Should request a registration without a channel, that will conflict with an already existing channel', async function () {
369 let id1: number
370 let id2: number
371
372 {
373 const { id } = await server.registrations.requestRegistration({
374 registrationReason: 'tt',
375 username: 'user5',
376 password: 'user5password',
377 channel: {
378 displayName: 'channel 6',
379 name: 'user6_channel'
380 }
381 })
382
383 id1 = id
384 }
385
386 {
387 const { id } = await server.registrations.requestRegistration({
388 registrationReason: 'tt',
389 username: 'user6',
390 password: 'user6password'
391 })
392
393 id2 = id
394 }
395
396 await server.registrations.accept({ id: id1, moderationResponse: 'tt' })
397 await server.registrations.accept({ id: id2, moderationResponse: 'tt' })
398
399 const user5Token = await server.login.getAccessToken('user5', 'user5password')
400 const user6Token = await server.login.getAccessToken('user6', 'user6password')
401
402 const user5 = await server.users.getMyInfo({ token: user5Token })
403 const user6 = await server.users.getMyInfo({ token: user6Token })
404
405 expect(user5.videoChannels[0].name).to.equal('user6_channel')
406 expect(user6.videoChannels[0].name).to.equal('user6_channel-1')
407 })
408 })
409
410 after(async function () {
411 MockSmtpServer.Instance.kill()
412
413 await cleanupTests([ server ])
414 })
415})
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-email-verification.ts
index 19a8df9e1..cb84dc758 100644
--- a/server/tests/api/users/users-verification.ts
+++ b/server/tests/api/users/users-email-verification.ts
@@ -3,9 +3,16 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { MockSmtpServer } from '@server/tests/shared' 4import { MockSmtpServer } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands' 6import {
7 7 cleanupTests,
8describe('Test users account verification', function () { 8 ConfigCommand,
9 createSingleServer,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 waitJobs
13} from '@shared/server-commands'
14
15describe('Test users email verification', function () {
9 let server: PeerTubeServer 16 let server: PeerTubeServer
10 let userId: number 17 let userId: number
11 let userAccessToken: string 18 let userAccessToken: string
@@ -25,14 +32,7 @@ describe('Test users account verification', function () {
25 this.timeout(30000) 32 this.timeout(30000)
26 33
27 const port = await MockSmtpServer.Instance.collectEmails(emails) 34 const port = await MockSmtpServer.Instance.collectEmails(emails)
28 35 server = await createSingleServer(1, ConfigCommand.getEmailOverrideConfig(port))
29 const overrideConfig = {
30 smtp: {
31 hostname: '127.0.0.1',
32 port
33 }
34 }
35 server = await createSingleServer(1, overrideConfig)
36 36
37 await setAccessTokensToServers([ server ]) 37 await setAccessTokensToServers([ server ])
38 }) 38 })
@@ -40,17 +40,18 @@ describe('Test users account verification', function () {
40 it('Should register user and send verification email if verification required', async function () { 40 it('Should register user and send verification email if verification required', async function () {
41 this.timeout(30000) 41 this.timeout(30000)
42 42
43 await server.config.updateCustomSubConfig({ 43 await server.config.updateExistingSubConfig({
44 newConfig: { 44 newConfig: {
45 signup: { 45 signup: {
46 enabled: true, 46 enabled: true,
47 requiresApproval: false,
47 requiresEmailVerification: true, 48 requiresEmailVerification: true,
48 limit: 10 49 limit: 10
49 } 50 }
50 } 51 }
51 }) 52 })
52 53
53 await server.users.register(user1) 54 await server.registrations.register(user1)
54 55
55 await waitJobs(server) 56 await waitJobs(server)
56 expectedEmailsLength++ 57 expectedEmailsLength++
@@ -127,17 +128,15 @@ describe('Test users account verification', function () {
127 128
128 it('Should register user not requiring email verification if setting not enabled', async function () { 129 it('Should register user not requiring email verification if setting not enabled', async function () {
129 this.timeout(5000) 130 this.timeout(5000)
130 await server.config.updateCustomSubConfig({ 131 await server.config.updateExistingSubConfig({
131 newConfig: { 132 newConfig: {
132 signup: { 133 signup: {
133 enabled: true, 134 requiresEmailVerification: false
134 requiresEmailVerification: false,
135 limit: 10
136 } 135 }
137 } 136 }
138 }) 137 })
139 138
140 await server.users.register(user2) 139 await server.registrations.register(user2)
141 140
142 await waitJobs(server) 141 await waitJobs(server)
143 expect(emails).to.have.lengthOf(expectedEmailsLength) 142 expect(emails).to.have.lengthOf(expectedEmailsLength)
@@ -152,9 +151,7 @@ describe('Test users account verification', function () {
152 await server.config.updateCustomSubConfig({ 151 await server.config.updateCustomSubConfig({
153 newConfig: { 152 newConfig: {
154 signup: { 153 signup: {
155 enabled: true, 154 requiresEmailVerification: true
156 requiresEmailVerification: true,
157 limit: 10
158 } 155 }
159 } 156 }
160 }) 157 })
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 421b3ce16..f1e170971 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -2,15 +2,8 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImage } from '@server/tests/shared'
5import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' 5import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
6import { 6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7 cleanupTests,
8 createSingleServer,
9 killallServers,
10 makePutBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
14 7
15describe('Test users', function () { 8describe('Test users', function () {
16 let server: PeerTubeServer 9 let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
39 await server.plugins.install({ npmName: 'peertube-theme-background-red' }) 32 await server.plugins.install({ npmName: 'peertube-theme-background-red' })
40 }) 33 })
41 34
42 describe('OAuth client', function () {
43 it('Should create a new client')
44
45 it('Should return the first client')
46
47 it('Should remove the last client')
48
49 it('Should not login with an invalid client id', async function () {
50 const client = { id: 'client', secret: server.store.client.secret }
51 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
52
53 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
54 expect(body.error).to.contain('client is invalid')
55 expect(body.type.startsWith('https://')).to.be.true
56 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
57 })
58
59 it('Should not login with an invalid client secret', async function () {
60 const client = { id: server.store.client.id, secret: 'coucou' }
61 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
62
63 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
64 expect(body.error).to.contain('client is invalid')
65 expect(body.type.startsWith('https://')).to.be.true
66 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
67 })
68 })
69
70 describe('Login', function () {
71
72 it('Should not login with an invalid username', async function () {
73 const user = { username: 'captain crochet', password: server.store.user.password }
74 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
75
76 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
77 expect(body.error).to.contain('credentials are invalid')
78 expect(body.type.startsWith('https://')).to.be.true
79 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
80 })
81
82 it('Should not login with an invalid password', async function () {
83 const user = { username: server.store.user.username, password: 'mew_three' }
84 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
87 expect(body.error).to.contain('credentials are invalid')
88 expect(body.type.startsWith('https://')).to.be.true
89 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
90 })
91
92 it('Should not be able to upload a video', async function () {
93 token = 'my_super_token'
94
95 await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
96 })
97
98 it('Should not be able to follow', async function () {
99 token = 'my_super_token'
100
101 await server.follows.follow({
102 hosts: [ 'http://example.com' ],
103 token,
104 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
105 })
106 })
107
108 it('Should not be able to unfollow')
109
110 it('Should be able to login', async function () {
111 const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
112
113 token = body.access_token
114 })
115
116 it('Should be able to login with an insensitive username', async function () {
117 const user = { username: 'RoOt', password: server.store.user.password }
118 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
119
120 const user2 = { username: 'rOoT', password: server.store.user.password }
121 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
122
123 const user3 = { username: 'ROOt', password: server.store.user.password }
124 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
125 })
126 })
127
128 describe('Logout', function () {
129 it('Should logout (revoke token)', async function () {
130 await server.login.logout({ token: server.accessToken })
131 })
132
133 it('Should not be able to get the user information', async function () {
134 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
135 })
136
137 it('Should not be able to upload a video', async function () {
138 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
139 })
140
141 it('Should not be able to rate a video', async function () {
142 const path = '/api/v1/videos/'
143 const data = {
144 rating: 'likes'
145 }
146
147 const options = {
148 url: server.url,
149 path: path + videoId,
150 token: 'wrong token',
151 fields: data,
152 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
153 }
154 await makePutBodyRequest(options)
155 })
156
157 it('Should be able to login again', async function () {
158 const body = await server.login.login()
159 server.accessToken = body.access_token
160 server.refreshToken = body.refresh_token
161 })
162
163 it('Should be able to get my user information again', async function () {
164 await server.users.getMyInfo()
165 })
166
167 it('Should have an expired access token', async function () {
168 this.timeout(60000)
169
170 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
171 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
172
173 await killallServers([ server ])
174 await server.run()
175
176 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
177 })
178
179 it('Should not be able to refresh an access token with an expired refresh token', async function () {
180 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 })
182
183 it('Should refresh the token', async function () {
184 this.timeout(50000)
185
186 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
187 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
188
189 await killallServers([ server ])
190 await server.run()
191
192 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
193 server.accessToken = res.body.access_token
194 server.refreshToken = res.body.refresh_token
195 })
196
197 it('Should be able to get my user information again', async function () {
198 await server.users.getMyInfo()
199 })
200 })
201
202 describe('Creating a user', function () { 35 describe('Creating a user', function () {
203 36
204 it('Should be able to create a new user', async function () { 37 it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
512 }) 345 })
513 346
514 describe('Updating another user', function () { 347 describe('Updating another user', function () {
348
515 it('Should be able to update another user', async function () { 349 it('Should be able to update another user', async function () {
516 await server.users.update({ 350 await server.users.update({
517 userId, 351 userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
562 }) 396 })
563 }) 397 })
564 398
565 describe('Video blacklists', function () {
566
567 it('Should be able to list my video blacklist', async function () {
568 await server.blacklist.list({ token: userToken })
569 })
570 })
571
572 describe('Remove a user', function () { 399 describe('Remove a user', function () {
573 400
574 before(async function () { 401 before(async function () {
@@ -602,59 +429,10 @@ describe('Test users', function () {
602 }) 429 })
603 }) 430 })
604 431
605 describe('Registering a new user', function () {
606 let user15AccessToken: string
607
608 it('Should register a new user', async function () {
609 const user = { displayName: 'super user 15', username: 'user_15', password: 'my super password' }
610 const channel = { name: 'my_user_15_channel', displayName: 'my channel rocks' }
611
612 await server.users.register({ ...user, channel })
613 })
614
615 it('Should be able to login with this registered user', async function () {
616 const user15 = {
617 username: 'user_15',
618 password: 'my super password'
619 }
620
621 user15AccessToken = await server.login.getAccessToken(user15)
622 })
623
624 it('Should have the correct display name', async function () {
625 const user = await server.users.getMyInfo({ token: user15AccessToken })
626 expect(user.account.displayName).to.equal('super user 15')
627 })
628
629 it('Should have the correct video quota', async function () {
630 const user = await server.users.getMyInfo({ token: user15AccessToken })
631 expect(user.videoQuota).to.equal(5 * 1024 * 1024)
632 })
633
634 it('Should have created the channel', async function () {
635 const { displayName } = await server.channels.get({ channelName: 'my_user_15_channel' })
636
637 expect(displayName).to.equal('my channel rocks')
638 })
639
640 it('Should remove me', async function () {
641 {
642 const { data } = await server.users.list()
643 expect(data.find(u => u.username === 'user_15')).to.not.be.undefined
644 }
645
646 await server.users.deleteMe({ token: user15AccessToken })
647
648 {
649 const { data } = await server.users.list()
650 expect(data.find(u => u.username === 'user_15')).to.be.undefined
651 }
652 })
653 })
654
655 describe('User blocking', function () { 432 describe('User blocking', function () {
656 let user16Id 433 let user16Id: number
657 let user16AccessToken 434 let user16AccessToken: string
435
658 const user16 = { 436 const user16 = {
659 username: 'user_16', 437 username: 'user_16',
660 password: 'my super password' 438 password: 'my super password'
diff --git a/server/tests/api/videos/video-channel-syncs.ts b/server/tests/api/videos/video-channel-syncs.ts
index 91291524d..dd483f95e 100644
--- a/server/tests/api/videos/video-channel-syncs.ts
+++ b/server/tests/api/videos/video-channel-syncs.ts
@@ -307,6 +307,7 @@ describe('Test channel synchronizations', function () {
307 }) 307 })
308 } 308 }
309 309
310 runSuite('youtube-dl') 310 // FIXME: suite is broken with youtube-dl
311 // runSuite('youtube-dl')
311 runSuite('yt-dlp') 312 runSuite('yt-dlp')
312}) 313})
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index dc47f8a4a..e35500b0b 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -38,6 +38,8 @@ describe('Test video comments', function () {
38 await setDefaultAccountAvatar(server) 38 await setDefaultAccountAvatar(server)
39 39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1') 40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
41 43
42 command = server.comments 44 command = server.comments
43 }) 45 })
@@ -167,6 +169,13 @@ describe('Test video comments', function () {
167 expect(body.data[2].totalReplies).to.equal(0) 169 expect(body.data[2].totalReplies).to.equal(0)
168 }) 170 })
169 171
172 it('Should list the and sort them by total replies', async function () {
173 const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' })
174
175 expect(body.data[2].text).to.equal('my super first comment')
176 expect(body.data[2].totalReplies).to.equal(3)
177 })
178
170 it('Should delete a reply', async function () { 179 it('Should delete a reply', async function () {
171 await command.delete({ videoId, commentId: replyToDeleteId }) 180 await command.delete({ videoId, commentId: replyToDeleteId })
172 181
@@ -232,16 +241,34 @@ describe('Test video comments', function () {
232 await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) 241 await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
233 242
234 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) 243 const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
235 expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1) 244 expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
245 expect(tree.comment.totalReplies).to.equal(2)
236 }) 246 })
237 }) 247 })
238 248
239 describe('All instance comments', function () { 249 describe('All instance comments', function () {
240 250
241 it('Should list instance comments as admin', async function () { 251 it('Should list instance comments as admin', async function () {
242 const { data } = await command.listForAdmin({ start: 0, count: 1 }) 252 {
253 const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
254
255 expect(total).to.equal(7)
256 expect(data).to.have.lengthOf(1)
257 expect(data[0].text).to.equal('my second answer to thread 4')
258 expect(data[0].account.name).to.equal('root')
259 expect(data[0].account.displayName).to.equal('root')
260 expect(data[0].account.avatars).to.have.lengthOf(2)
261 }
262
263 {
264 const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
243 265
244 expect(data[0].text).to.equal('my second answer to thread 4') 266 expect(total).to.equal(7)
267 expect(data).to.have.lengthOf(2)
268
269 expect(data[0].account.avatars).to.have.lengthOf(2)
270 expect(data[1].account.avatars).to.have.lengthOf(2)
271 }
245 }) 272 })
246 273
247 it('Should filter instance comments by isLocal', async function () { 274 it('Should filter instance comments by isLocal', async function () {
diff --git a/server/tests/api/videos/video-imports.ts b/server/tests/api/videos/video-imports.ts
index 0583134b2..5636de45f 100644
--- a/server/tests/api/videos/video-imports.ts
+++ b/server/tests/api/videos/video-imports.ts
@@ -41,7 +41,7 @@ async function checkVideosServer1 (server: PeerTubeServer, idHttp: string, idMag
41 const videoTorrent = await server.videos.get({ id: idTorrent }) 41 const videoTorrent = await server.videos.get({ id: idTorrent })
42 42
43 for (const video of [ videoMagnet, videoTorrent ]) { 43 for (const video of [ videoMagnet, videoTorrent ]) {
44 expect(video.category.label).to.equal('Misc') 44 expect(video.category.label).to.equal('Unknown')
45 expect(video.licence.label).to.equal('Unknown') 45 expect(video.licence.label).to.equal('Unknown')
46 expect(video.language.label).to.equal('Unknown') 46 expect(video.language.label).to.equal('Unknown')
47 expect(video.nsfw).to.be.false 47 expect(video.nsfw).to.be.false
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 6a18cf26a..e8e653382 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -3,6 +3,7 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared' 4import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared'
5import { wait } from '@shared/core-utils' 5import { wait } from '@shared/core-utils'
6import { uuidToShort } from '@shared/extra-utils'
6import { 7import {
7 HttpStatusCode, 8 HttpStatusCode,
8 VideoPlaylist, 9 VideoPlaylist,
@@ -23,7 +24,6 @@ import {
23 setDefaultVideoChannel, 24 setDefaultVideoChannel,
24 waitJobs 25 waitJobs
25} from '@shared/server-commands' 26} from '@shared/server-commands'
26import { uuidToShort } from '@shared/extra-utils'
27 27
28async function checkPlaylistElementType ( 28async function checkPlaylistElementType (
29 servers: PeerTubeServer[], 29 servers: PeerTubeServer[],
@@ -752,19 +752,6 @@ describe('Test video playlists', function () {
752 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) 752 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
753 } 753 }
754 }) 754 })
755
756 it('Should hide the video if it is NSFW', async function () {
757 const body = await commands[0].listVideos({ token: userTokenServer1, playlistId: playlistServer1UUID2, query: { nsfw: 'false' } })
758 expect(body.total).to.equal(3)
759
760 const elements = body.data
761 const element = elements.find(e => e.position === 3)
762
763 expect(element).to.exist
764 expect(element.video).to.be.null
765 expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
766 })
767
768 }) 755 })
769 756
770 describe('Managing playlist elements', function () { 757 describe('Managing playlist elements', function () {
diff --git a/server/tests/external-plugins/akismet.ts b/server/tests/external-plugins/akismet.ts
index 974bf0011..e964bf0c2 100644
--- a/server/tests/external-plugins/akismet.ts
+++ b/server/tests/external-plugins/akismet.ts
@@ -138,14 +138,14 @@ describe('Official plugin Akismet', function () {
138 }) 138 })
139 139
140 it('Should allow signup', async function () { 140 it('Should allow signup', async function () {
141 await servers[0].users.register({ 141 await servers[0].registrations.register({
142 username: 'user1', 142 username: 'user1',
143 displayName: 'user 1' 143 displayName: 'user 1'
144 }) 144 })
145 }) 145 })
146 146
147 it('Should detect a signup as SPAM', async function () { 147 it('Should detect a signup as SPAM', async function () {
148 await servers[0].users.register({ 148 await servers[0].registrations.register({
149 username: 'user2', 149 username: 'user2',
150 displayName: 'user 2', 150 displayName: 'user 2',
151 email: 'akismet-guaranteed-spam@example.com', 151 email: 'akismet-guaranteed-spam@example.com',
diff --git a/server/tests/external-plugins/auto-block-videos.ts b/server/tests/external-plugins/auto-block-videos.ts
index d14587c38..cadd02e8d 100644
--- a/server/tests/external-plugins/auto-block-videos.ts
+++ b/server/tests/external-plugins/auto-block-videos.ts
@@ -30,7 +30,7 @@ describe('Official plugin auto-block videos', function () {
30 let port: number 30 let port: number
31 31
32 before(async function () { 32 before(async function () {
33 this.timeout(60000) 33 this.timeout(120000)
34 34
35 servers = await createMultipleServers(2) 35 servers = await createMultipleServers(2)
36 await setAccessTokensToServers(servers) 36 await setAccessTokensToServers(servers)
diff --git a/server/tests/external-plugins/auto-mute.ts b/server/tests/external-plugins/auto-mute.ts
index 440b58bfd..cfed76e88 100644
--- a/server/tests/external-plugins/auto-mute.ts
+++ b/server/tests/external-plugins/auto-mute.ts
@@ -21,7 +21,7 @@ describe('Official plugin auto-mute', function () {
21 let port: number 21 let port: number
22 22
23 before(async function () { 23 before(async function () {
24 this.timeout(30000) 24 this.timeout(120000)
25 25
26 servers = await createMultipleServers(2) 26 servers = await createMultipleServers(2)
27 await setAccessTokensToServers(servers) 27 await setAccessTokensToServers(servers)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 906dab1a3..7345f728a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -189,7 +189,7 @@ describe('Test syndication feeds', () => {
189 const jsonObj = JSON.parse(json) 189 const jsonObj = JSON.parse(json)
190 expect(jsonObj.items.length).to.be.equal(1) 190 expect(jsonObj.items.length).to.be.equal(1)
191 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 191 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
192 expect(jsonObj.items[0].author.name).to.equal('root') 192 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
193 } 193 }
194 194
195 { 195 {
@@ -197,7 +197,7 @@ describe('Test syndication feeds', () => {
197 const jsonObj = JSON.parse(json) 197 const jsonObj = JSON.parse(json)
198 expect(jsonObj.items.length).to.be.equal(1) 198 expect(jsonObj.items.length).to.be.equal(1)
199 expect(jsonObj.items[0].title).to.equal('user video') 199 expect(jsonObj.items[0].title).to.equal('user video')
200 expect(jsonObj.items[0].author.name).to.equal('john') 200 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
201 } 201 }
202 202
203 for (const server of servers) { 203 for (const server of servers) {
@@ -223,7 +223,7 @@ describe('Test syndication feeds', () => {
223 const jsonObj = JSON.parse(json) 223 const jsonObj = JSON.parse(json)
224 expect(jsonObj.items.length).to.be.equal(1) 224 expect(jsonObj.items.length).to.be.equal(1)
225 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 225 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
226 expect(jsonObj.items[0].author.name).to.equal('root') 226 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
227 } 227 }
228 228
229 { 229 {
@@ -231,7 +231,7 @@ describe('Test syndication feeds', () => {
231 const jsonObj = JSON.parse(json) 231 const jsonObj = JSON.parse(json)
232 expect(jsonObj.items.length).to.be.equal(1) 232 expect(jsonObj.items.length).to.be.equal(1)
233 expect(jsonObj.items[0].title).to.equal('user video') 233 expect(jsonObj.items[0].title).to.equal('user video')
234 expect(jsonObj.items[0].author.name).to.equal('john') 234 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
235 } 235 }
236 236
237 for (const server of servers) { 237 for (const server of servers) {
diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
index c65b8d3a8..58bc27661 100644
--- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
@@ -33,7 +33,17 @@ async function register ({
33 username: 'kefka', 33 username: 'kefka',
34 email: 'kefka@example.com', 34 email: 'kefka@example.com',
35 role: 0, 35 role: 0,
36 displayName: 'Kefka Palazzo' 36 displayName: 'Kefka Palazzo',
37 adminFlags: 1,
38 videoQuota: 42000,
39 videoQuotaDaily: 42100,
40
41 // Always use new value except for videoQuotaDaily field
42 userUpdater: ({ fieldName, currentValue, newValue }) => {
43 if (fieldName === 'videoQuotaDaily') return currentValue
44
45 return newValue
46 }
37 }) 47 })
38 }, 48 },
39 hookTokenValidity: (options) => { 49 hookTokenValidity: (options) => {
diff --git a/server/tests/fixtures/peertube-plugin-test-four/main.js b/server/tests/fixtures/peertube-plugin-test-four/main.js
index 3e848c49e..b10177b45 100644
--- a/server/tests/fixtures/peertube-plugin-test-four/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-four/main.js
@@ -76,6 +76,12 @@ async function register ({
76 return res.json({ serverConfig }) 76 return res.json({ serverConfig })
77 }) 77 })
78 78
79 router.get('/server-listening-config', async (req, res) => {
80 const config = await peertubeHelpers.config.getServerListeningConfig()
81
82 return res.json({ config })
83 })
84
79 router.get('/static-route', async (req, res) => { 85 router.get('/static-route', async (req, res) => {
80 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute() 86 const staticRoute = peertubeHelpers.plugin.getBaseStaticRoute()
81 87
diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
index ceab7b60d..fad5abf60 100644
--- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
@@ -33,7 +33,18 @@ async function register ({
33 if (body.id === 'laguna' && body.password === 'laguna password') { 33 if (body.id === 'laguna' && body.password === 'laguna password') {
34 return Promise.resolve({ 34 return Promise.resolve({
35 username: 'laguna', 35 username: 'laguna',
36 email: 'laguna@example.com' 36 email: 'laguna@example.com',
37 displayName: 'Laguna Loire',
38 adminFlags: 1,
39 videoQuota: 42000,
40 videoQuotaDaily: 42100,
41
42 // Always use new value except for videoQuotaDaily field
43 userUpdater: ({ fieldName, currentValue, newValue }) => {
44 if (fieldName === 'videoQuotaDaily') return currentValue
45
46 return newValue
47 }
37 }) 48 })
38 } 49 }
39 50
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 19dccf26e..5b4d34f15 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -226,16 +226,29 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
226 } 226 }
227 }) 227 })
228 228
229 registerHook({ 229 {
230 target: 'filter:api.user.signup.allowed.result', 230 registerHook({
231 handler: (result, params) => { 231 target: 'filter:api.user.signup.allowed.result',
232 if (params && params.body && params.body.email && params.body.email.includes('jma')) { 232 handler: (result, params) => {
233 return { allowed: false, errorMessage: 'No jma' } 233 if (params && params.body && params.body.email && params.body.email.includes('jma 1')) {
234 return { allowed: false, errorMessage: 'No jma 1' }
235 }
236
237 return result
234 } 238 }
239 })
235 240
236 return result 241 registerHook({
237 } 242 target: 'filter:api.user.request-signup.allowed.result',
238 }) 243 handler: (result, params) => {
244 if (params && params.body && params.body.email && params.body.email.includes('jma 2')) {
245 return { allowed: false, errorMessage: 'No jma 2' }
246 }
247
248 return result
249 }
250 })
251 }
239 252
240 registerHook({ 253 registerHook({
241 target: 'filter:api.download.torrent.allowed.result', 254 target: 'filter:api.download.torrent.allowed.result',
@@ -250,7 +263,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
250 263
251 registerHook({ 264 registerHook({
252 target: 'filter:api.download.video.allowed.result', 265 target: 'filter:api.download.video.allowed.result',
253 handler: (result, params) => { 266 handler: async (result, params) => {
267 const loggedInUser = await peertubeHelpers.user.getAuthUser(params.res)
268 if (loggedInUser) return { allowed: true }
269
254 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) { 270 if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
255 return { allowed: false, errorMessage: 'Cao Cao' } 271 return { allowed: false, errorMessage: 'Cao Cao' }
256 } 272 }
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
index 1b5c6d15b..073ae6455 100644
--- a/server/tests/helpers/index.ts
+++ b/server/tests/helpers/index.ts
@@ -6,3 +6,4 @@ import './image'
6import './markdown' 6import './markdown'
7import './request' 7import './request'
8import './validator' 8import './validator'
9import './version'
diff --git a/server/tests/helpers/version.ts b/server/tests/helpers/version.ts
new file mode 100644
index 000000000..2a90efba3
--- /dev/null
+++ b/server/tests/helpers/version.ts
@@ -0,0 +1,36 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { compareSemVer } from '@shared/core-utils'
5
6describe('Version', function () {
7
8 it('Should correctly compare two stable versions', async function () {
9 expect(compareSemVer('3.4.0', '3.5.0')).to.be.below(0)
10 expect(compareSemVer('3.5.0', '3.4.0')).to.be.above(0)
11
12 expect(compareSemVer('3.4.0', '4.1.0')).to.be.below(0)
13 expect(compareSemVer('4.1.0', '3.4.0')).to.be.above(0)
14
15 expect(compareSemVer('3.4.0', '3.4.1')).to.be.below(0)
16 expect(compareSemVer('3.4.1', '3.4.0')).to.be.above(0)
17 })
18
19 it('Should correctly compare two unstable version', async function () {
20 expect(compareSemVer('3.4.0-alpha', '3.4.0-beta.1')).to.be.below(0)
21 expect(compareSemVer('3.4.0-alpha.1', '3.4.0-beta.1')).to.be.below(0)
22 expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
23 expect(compareSemVer('3.4.0-beta.1', '3.5.0-alpha.1')).to.be.below(0)
24
25 expect(compareSemVer('3.4.0-alpha.1', '3.4.0-nightly.4')).to.be.below(0)
26 expect(compareSemVer('3.4.0-nightly.3', '3.4.0-nightly.4')).to.be.below(0)
27 expect(compareSemVer('3.3.0-nightly.5', '3.4.0-nightly.4')).to.be.below(0)
28 })
29
30 it('Should correctly compare a stable and unstable versions', async function () {
31 expect(compareSemVer('3.4.0', '3.4.1-beta.1')).to.be.below(0)
32 expect(compareSemVer('3.4.0-beta.1', '3.4.0-beta.2')).to.be.below(0)
33 expect(compareSemVer('3.4.0-beta.1', '3.4.0')).to.be.below(0)
34 expect(compareSemVer('3.4.0-nightly.4', '3.4.0')).to.be.below(0)
35 })
36})
diff --git a/server/tests/plugins/action-hooks.ts b/server/tests/plugins/action-hooks.ts
index 36f8052c0..a266ae7f1 100644
--- a/server/tests/plugins/action-hooks.ts
+++ b/server/tests/plugins/action-hooks.ts
@@ -153,7 +153,7 @@ describe('Test plugin action hooks', function () {
153 let userId: number 153 let userId: number
154 154
155 it('Should run action:api.user.registered', async function () { 155 it('Should run action:api.user.registered', async function () {
156 await servers[0].users.register({ username: 'registered_user' }) 156 await servers[0].registrations.register({ username: 'registered_user' })
157 157
158 await checkHook('action:api.user.registered') 158 await checkHook('action:api.user.registered')
159 }) 159 })
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts
index 437777e90..e600f958f 100644
--- a/server/tests/plugins/external-auth.ts
+++ b/server/tests/plugins/external-auth.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { wait } from '@shared/core-utils' 4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createSingleServer, 8 createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
51 51
52 let kefkaAccessToken: string 52 let kefkaAccessToken: string
53 let kefkaRefreshToken: string 53 let kefkaRefreshToken: string
54 let kefkaId: number
54 55
55 let externalAuthToken: string 56 let externalAuthToken: string
56 57
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
156 expect(body.account.displayName).to.equal('cyan') 157 expect(body.account.displayName).to.equal('cyan')
157 expect(body.email).to.equal('cyan@example.com') 158 expect(body.email).to.equal('cyan@example.com')
158 expect(body.role.id).to.equal(UserRole.USER) 159 expect(body.role.id).to.equal(UserRole.USER)
160 expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
161 expect(body.videoQuota).to.equal(5242880)
162 expect(body.videoQuotaDaily).to.equal(-1)
159 } 163 }
160 }) 164 })
161 165
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
178 expect(body.account.displayName).to.equal('Kefka Palazzo') 182 expect(body.account.displayName).to.equal('Kefka Palazzo')
179 expect(body.email).to.equal('kefka@example.com') 183 expect(body.email).to.equal('kefka@example.com')
180 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) 184 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
185 expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
186 expect(body.videoQuota).to.equal(42000)
187 expect(body.videoQuotaDaily).to.equal(42100)
188
189 kefkaId = body.id
181 } 190 }
182 }) 191 })
183 192
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
240 expect(body.role.id).to.equal(UserRole.USER) 249 expect(body.role.id).to.equal(UserRole.USER)
241 }) 250 })
242 251
252 it('Should login Kefka and update the profile', async function () {
253 {
254 await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
255 await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
256
257 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
258 expect(body.username).to.equal('kefka')
259 expect(body.account.displayName).to.equal('kefka updated')
260 expect(body.videoQuota).to.equal(43000)
261 expect(body.videoQuotaDaily).to.equal(43100)
262 }
263
264 {
265 const res = await loginExternal({
266 server,
267 npmName: 'test-external-auth-one',
268 authName: 'external-auth-2',
269 username: 'kefka'
270 })
271
272 kefkaAccessToken = res.access_token
273 kefkaRefreshToken = res.refresh_token
274
275 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
276 expect(body.username).to.equal('kefka')
277 expect(body.account.displayName).to.equal('Kefka Palazzo')
278 expect(body.videoQuota).to.equal(42000)
279 expect(body.videoQuotaDaily).to.equal(43100)
280 }
281 })
282
243 it('Should not update an external auth email', async function () { 283 it('Should not update an external auth email', async function () {
244 await server.users.updateMe({ 284 await server.users.updateMe({
245 token: cyanAccessToken, 285 token: cyanAccessToken,
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 083fd43ca..37eef6cf3 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -1,7 +1,15 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { HttpStatusCode, VideoDetails, VideoImportState, VideoPlaylist, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' 4import {
5 HttpStatusCode,
6 PeerTubeProblemDocument,
7 VideoDetails,
8 VideoImportState,
9 VideoPlaylist,
10 VideoPlaylistPrivacy,
11 VideoPrivacy
12} from '@shared/models'
5import { 13import {
6 cleanupTests, 14 cleanupTests,
7 createMultipleServers, 15 createMultipleServers,
@@ -408,28 +416,58 @@ describe('Test plugin filter hooks', function () {
408 416
409 describe('Should run filter:api.user.signup.allowed.result', function () { 417 describe('Should run filter:api.user.signup.allowed.result', function () {
410 418
419 before(async function () {
420 await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: false } } })
421 })
422
411 it('Should run on config endpoint', async function () { 423 it('Should run on config endpoint', async function () {
412 const body = await servers[0].config.getConfig() 424 const body = await servers[0].config.getConfig()
413 expect(body.signup.allowed).to.be.true 425 expect(body.signup.allowed).to.be.true
414 }) 426 })
415 427
416 it('Should allow a signup', async function () { 428 it('Should allow a signup', async function () {
417 await servers[0].users.register({ username: 'john', password: 'password' }) 429 await servers[0].registrations.register({ username: 'john1' })
418 }) 430 })
419 431
420 it('Should not allow a signup', async function () { 432 it('Should not allow a signup', async function () {
421 const res = await servers[0].users.register({ 433 const res = await servers[0].registrations.register({
422 username: 'jma', 434 username: 'jma 1',
423 password: 'password',
424 expectedStatus: HttpStatusCode.FORBIDDEN_403 435 expectedStatus: HttpStatusCode.FORBIDDEN_403
425 }) 436 })
426 437
427 expect(res.body.error).to.equal('No jma') 438 expect(res.body.error).to.equal('No jma 1')
439 })
440 })
441
442 describe('Should run filter:api.user.request-signup.allowed.result', function () {
443
444 before(async function () {
445 await servers[0].config.updateExistingSubConfig({ newConfig: { signup: { requiresApproval: true } } })
446 })
447
448 it('Should run on config endpoint', async function () {
449 const body = await servers[0].config.getConfig()
450 expect(body.signup.allowed).to.be.true
451 })
452
453 it('Should allow a signup request', async function () {
454 await servers[0].registrations.requestRegistration({ username: 'john2', registrationReason: 'tt' })
455 })
456
457 it('Should not allow a signup request', async function () {
458 const body = await servers[0].registrations.requestRegistration({
459 username: 'jma 2',
460 registrationReason: 'tt',
461 expectedStatus: HttpStatusCode.FORBIDDEN_403
462 })
463
464 expect((body as unknown as PeerTubeProblemDocument).error).to.equal('No jma 2')
428 }) 465 })
429 }) 466 })
430 467
431 describe('Download hooks', function () { 468 describe('Download hooks', function () {
432 const downloadVideos: VideoDetails[] = [] 469 const downloadVideos: VideoDetails[] = []
470 let downloadVideo2Token: string
433 471
434 before(async function () { 472 before(async function () {
435 this.timeout(120000) 473 this.timeout(120000)
@@ -459,6 +497,8 @@ describe('Test plugin filter hooks', function () {
459 for (const uuid of uuids) { 497 for (const uuid of uuids) {
460 downloadVideos.push(await servers[0].videos.get({ id: uuid })) 498 downloadVideos.push(await servers[0].videos.get({ id: uuid }))
461 } 499 }
500
501 downloadVideo2Token = await servers[0].videoToken.getVideoFileToken({ videoId: downloadVideos[2].uuid })
462 }) 502 })
463 503
464 it('Should run filter:api.download.torrent.allowed.result', async function () { 504 it('Should run filter:api.download.torrent.allowed.result', async function () {
@@ -471,32 +511,42 @@ describe('Test plugin filter hooks', function () {
471 511
472 it('Should run filter:api.download.video.allowed.result', async function () { 512 it('Should run filter:api.download.video.allowed.result', async function () {
473 { 513 {
474 const res = await makeRawRequest({ url: downloadVideos[1].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 514 const refused = downloadVideos[1].files[0].fileDownloadUrl
515 const allowed = [
516 downloadVideos[0].files[0].fileDownloadUrl,
517 downloadVideos[2].files[0].fileDownloadUrl
518 ]
519
520 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
475 expect(res.body.error).to.equal('Cao Cao') 521 expect(res.body.error).to.equal('Cao Cao')
476 522
477 await makeRawRequest({ url: downloadVideos[0].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 523 for (const url of allowed) {
478 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 524 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
525 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
526 }
479 } 527 }
480 528
481 { 529 {
482 const res = await makeRawRequest({ 530 const refused = downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl
483 url: downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl,
484 expectedStatus: HttpStatusCode.FORBIDDEN_403
485 })
486 531
487 expect(res.body.error).to.equal('Sun Jian') 532 const allowed = [
533 downloadVideos[2].files[0].fileDownloadUrl,
534 downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl,
535 downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl
536 ]
488 537
489 await makeRawRequest({ url: downloadVideos[2].files[0].fileDownloadUrl, expectedStatus: HttpStatusCode.OK_200 }) 538 // Only streaming playlist is refuse
539 const res = await makeRawRequest({ url: refused, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
540 expect(res.body.error).to.equal('Sun Jian')
490 541
491 await makeRawRequest({ 542 // But not we there is a user in res
492 url: downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 543 await makeRawRequest({ url: refused, token: servers[0].accessToken, expectedStatus: HttpStatusCode.OK_200 })
493 expectedStatus: HttpStatusCode.OK_200 544 await makeRawRequest({ url: refused, query: { videoFileToken: downloadVideo2Token }, expectedStatus: HttpStatusCode.OK_200 })
494 })
495 545
496 await makeRawRequest({ 546 // Other files work
497 url: downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 547 for (const url of allowed) {
498 expectedStatus: HttpStatusCode.OK_200 548 await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
499 }) 549 }
500 } 550 }
501 }) 551 })
502 }) 552 })
diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts
index fc24a5656..10155c28b 100644
--- a/server/tests/plugins/id-and-pass-auth.ts
+++ b/server/tests/plugins/id-and-pass-auth.ts
@@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () {
13 13
14 let lagunaAccessToken: string 14 let lagunaAccessToken: string
15 let lagunaRefreshToken: string 15 let lagunaRefreshToken: string
16 let lagunaId: number
16 17
17 before(async function () { 18 before(async function () {
18 this.timeout(30000) 19 this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
78 const body = await server.users.getMyInfo({ token: lagunaAccessToken }) 79 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
79 80
80 expect(body.username).to.equal('laguna') 81 expect(body.username).to.equal('laguna')
81 expect(body.account.displayName).to.equal('laguna') 82 expect(body.account.displayName).to.equal('Laguna Loire')
82 expect(body.role.id).to.equal(UserRole.USER) 83 expect(body.role.id).to.equal(UserRole.USER)
84
85 lagunaId = body.id
83 } 86 }
84 }) 87 })
85 88
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
132 expect(body.role.id).to.equal(UserRole.MODERATOR) 135 expect(body.role.id).to.equal(UserRole.MODERATOR)
133 }) 136 })
134 137
138 it('Should login Laguna and update the profile', async function () {
139 {
140 await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
141 await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
142
143 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
144 expect(body.username).to.equal('laguna')
145 expect(body.account.displayName).to.equal('laguna updated')
146 expect(body.videoQuota).to.equal(43000)
147 expect(body.videoQuotaDaily).to.equal(43100)
148 }
149
150 {
151 const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
152 lagunaAccessToken = body.access_token
153 lagunaRefreshToken = body.refresh_token
154 }
155
156 {
157 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
158 expect(body.username).to.equal('laguna')
159 expect(body.account.displayName).to.equal('Laguna Loire')
160 expect(body.videoQuota).to.equal(42000)
161 expect(body.videoQuotaDaily).to.equal(43100)
162 }
163 })
164
135 it('Should reject token of laguna by the plugin hook', async function () { 165 it('Should reject token of laguna by the plugin hook', async function () {
136 this.timeout(10000) 166 this.timeout(10000)
137 167
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
147 await server.servers.waitUntilLog('valid username') 177 await server.servers.waitUntilLog('valid username')
148 178
149 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
150 await server.servers.waitUntilLog('valid display name') 180 await server.servers.waitUntilLog('valid displayName')
151 181
152 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 182 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
153 await server.servers.waitUntilLog('valid role') 183 await server.servers.waitUntilLog('valid role')
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index 038e3f0d6..e25992723 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -64,6 +64,18 @@ describe('Test plugin helpers', function () {
64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`) 64 await servers[0].servers.waitUntilLog(`server url is ${servers[0].url}`)
65 }) 65 })
66 66
67 it('Should have the correct listening config', async function () {
68 const res = await makeGetRequest({
69 url: servers[0].url,
70 path: '/plugins/test-four/router/server-listening-config',
71 expectedStatus: HttpStatusCode.OK_200
72 })
73
74 expect(res.body.config).to.exist
75 expect(res.body.config.hostname).to.equal('::')
76 expect(res.body.config.port).to.equal(servers[0].port)
77 })
78
67 it('Should have the correct config', async function () { 79 it('Should have the correct config', async function () {
68 const res = await makeGetRequest({ 80 const res = await makeGetRequest({
69 url: servers[0].url, 81 url: servers[0].url,
diff --git a/server/tests/shared/notifications.ts b/server/tests/shared/notifications.ts
index e600bd6b2..6c0688d5a 100644
--- a/server/tests/shared/notifications.ts
+++ b/server/tests/shared/notifications.ts
@@ -11,6 +11,7 @@ import {
11 UserNotificationType 11 UserNotificationType
12} from '@shared/models' 12} from '@shared/models'
13import { 13import {
14 ConfigCommand,
14 createMultipleServers, 15 createMultipleServers,
15 doubleFollow, 16 doubleFollow,
16 PeerTubeServer, 17 PeerTubeServer,
@@ -173,6 +174,8 @@ async function checkMyVideoImportIsFinished (options: CheckerBaseParams & {
173 await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) 174 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
174} 175}
175 176
177// ---------------------------------------------------------------------------
178
176async function checkUserRegistered (options: CheckerBaseParams & { 179async function checkUserRegistered (options: CheckerBaseParams & {
177 username: string 180 username: string
178 checkType: CheckerType 181 checkType: CheckerType
@@ -201,6 +204,36 @@ async function checkUserRegistered (options: CheckerBaseParams & {
201 await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) 204 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
202} 205}
203 206
207async function checkRegistrationRequest (options: CheckerBaseParams & {
208 username: string
209 registrationReason: string
210 checkType: CheckerType
211}) {
212 const { username, registrationReason } = options
213 const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST
214
215 function notificationChecker (notification: UserNotification, checkType: CheckerType) {
216 if (checkType === 'presence') {
217 expect(notification).to.not.be.undefined
218 expect(notification.type).to.equal(notificationType)
219
220 expect(notification.registration.username).to.equal(username)
221 } else {
222 expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username)
223 }
224 }
225
226 function emailNotificationFinder (email: object) {
227 const text: string = email['text']
228
229 return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason)
230 }
231
232 await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
233}
234
235// ---------------------------------------------------------------------------
236
204async function checkNewActorFollow (options: CheckerBaseParams & { 237async function checkNewActorFollow (options: CheckerBaseParams & {
205 followType: 'channel' | 'account' 238 followType: 'channel' | 'account'
206 followerName: string 239 followerName: string
@@ -673,10 +706,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
673 const port = await MockSmtpServer.Instance.collectEmails(emails) 706 const port = await MockSmtpServer.Instance.collectEmails(emails)
674 707
675 const overrideConfig = { 708 const overrideConfig = {
676 smtp: { 709 ...ConfigCommand.getEmailOverrideConfig(port),
677 hostname: '127.0.0.1', 710
678 port
679 },
680 signup: { 711 signup: {
681 limit: 20 712 limit: 20
682 } 713 }
@@ -735,7 +766,8 @@ async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: an
735 userAccessToken, 766 userAccessToken,
736 emails, 767 emails,
737 servers, 768 servers,
738 channelId 769 channelId,
770 baseOverrideConfig: overrideConfig
739 } 771 }
740} 772}
741 773
@@ -765,7 +797,8 @@ export {
765 checkNewAccountAbuseForModerators, 797 checkNewAccountAbuseForModerators,
766 checkNewPeerTubeVersion, 798 checkNewPeerTubeVersion,
767 checkNewPluginVersion, 799 checkNewPluginVersion,
768 checkVideoStudioEditionIsFinished 800 checkVideoStudioEditionIsFinished,
801 checkRegistrationRequest
769} 802}
770 803
771// --------------------------------------------------------------------------- 804// ---------------------------------------------------------------------------
diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts
index c8339584b..f8ec65752 100644
--- a/server/tests/shared/videos.ts
+++ b/server/tests/shared/videos.ts
@@ -59,7 +59,7 @@ async function completeVideoCheck (
59 59
60 expect(video.name).to.equal(attributes.name) 60 expect(video.name).to.equal(attributes.name)
61 expect(video.category.id).to.equal(attributes.category) 61 expect(video.category.id).to.equal(attributes.category)
62 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Misc') 62 expect(video.category.label).to.equal(attributes.category !== null ? VIDEO_CATEGORIES[attributes.category] : 'Unknown')
63 expect(video.licence.id).to.equal(attributes.licence) 63 expect(video.licence.id).to.equal(attributes.licence)
64 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown') 64 expect(video.licence.label).to.equal(attributes.licence !== null ? VIDEO_LICENCES[attributes.licence] : 'Unknown')
65 expect(video.language.id).to.equal(attributes.language) 65 expect(video.language.id).to.equal(attributes.language)
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 3738ffc47..c1c379b98 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -1,4 +1,3 @@
1
2import { OutgoingHttpHeaders } from 'http' 1import { OutgoingHttpHeaders } from 'http'
3import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
4import { 3import {
@@ -9,7 +8,9 @@ import {
9 MActorUrl, 8 MActorUrl,
10 MChannelBannerAccountDefault, 9 MChannelBannerAccountDefault,
11 MChannelSyncChannel, 10 MChannelSyncChannel,
11 MRegistration,
12 MStreamingPlaylist, 12 MStreamingPlaylist,
13 MUserAccountUrl,
13 MVideoChangeOwnershipFull, 14 MVideoChangeOwnershipFull,
14 MVideoFile, 15 MVideoFile,
15 MVideoFormattableDetails, 16 MVideoFormattableDetails,
@@ -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
@@ -187,6 +189,10 @@ declare module 'express' {
187 actor: MActorAccountChannelId 189 actor: MActorAccountChannelId
188 } 190 }
189 191
192 videoFileToken?: {
193 user: MUserAccountUrl
194 }
195
190 authenticated?: boolean 196 authenticated?: boolean
191 197
192 registeredPlugin?: RegisteredPlugin 198 registeredPlugin?: RegisteredPlugin
diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts
new file mode 100644
index 000000000..c901e2032
--- /dev/null
+++ b/server/types/lib.d.ts
@@ -0,0 +1,12 @@
1type ObjectKeys<T> =
2 T extends object
3 ? `${Exclude<keyof T, symbol>}`[]
4 : T extends number
5 ? []
6 : T extends any | string
7 ? string[]
8 : never
9
10interface ObjectConstructor {
11 keys<T> (o: T): ObjectKeys<T>
12}
diff --git a/server/types/models/user/index.ts b/server/types/models/user/index.ts
index 6657b2128..5738f4107 100644
--- a/server/types/models/user/index.ts
+++ b/server/types/models/user/index.ts
@@ -1,4 +1,5 @@
1export * from './user' 1export * from './user'
2export * from './user-notification' 2export * from './user-notification'
3export * from './user-notification-setting' 3export * from './user-notification-setting'
4export * from './user-registration'
4export * from './user-video-history' 5export * from './user-video-history'
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index d4715a0b6..a732c8aa9 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -3,6 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse
3import { ApplicationModel } from '@server/models/application/application' 3import { ApplicationModel } from '@server/models/application/application'
4import { PluginModel } from '@server/models/server/plugin' 4import { PluginModel } from '@server/models/server/plugin'
5import { UserNotificationModel } from '@server/models/user/user-notification' 5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { UserRegistrationModel } from '@server/models/user/user-registration'
6import { PickWith, PickWithOpt } from '@shared/typescript-utils' 7import { PickWith, PickWithOpt } from '@shared/typescript-utils'
7import { AbuseModel } from '../../../models/abuse/abuse' 8import { AbuseModel } from '../../../models/abuse/abuse'
8import { AccountModel } from '../../../models/account/account' 9import { AccountModel } from '../../../models/account/account'
@@ -94,13 +95,16 @@ export module UserNotificationIncludes {
94 95
95 export type ApplicationInclude = 96 export type ApplicationInclude =
96 Pick<ApplicationModel, 'latestPeerTubeVersion'> 97 Pick<ApplicationModel, 'latestPeerTubeVersion'>
98
99 export type UserRegistrationInclude =
100 Pick<UserRegistrationModel, 'id' | 'username'>
97} 101}
98 102
99// ############################################################################ 103// ############################################################################
100 104
101export type MUserNotification = 105export type MUserNotification =
102 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' | 106 Omit<UserNotificationModel, 'User' | 'Video' | 'VideoComment' | 'Abuse' | 'VideoBlacklist' |
103 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application'> 107 'VideoImport' | 'Account' | 'ActorFollow' | 'Plugin' | 'Application' | 'UserRegistration'>
104 108
105// ############################################################################ 109// ############################################################################
106 110
@@ -114,4 +118,5 @@ export type UserNotificationModelForApi =
114 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & 118 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
115 Use<'Plugin', UserNotificationIncludes.PluginInclude> & 119 Use<'Plugin', UserNotificationIncludes.PluginInclude> &
116 Use<'Application', UserNotificationIncludes.ApplicationInclude> & 120 Use<'Application', UserNotificationIncludes.ApplicationInclude> &
117 Use<'Account', UserNotificationIncludes.AccountIncludeActor> 121 Use<'Account', UserNotificationIncludes.AccountIncludeActor> &
122 Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude>
diff --git a/server/types/models/user/user-registration.ts b/server/types/models/user/user-registration.ts
new file mode 100644
index 000000000..216423cc9
--- /dev/null
+++ b/server/types/models/user/user-registration.ts
@@ -0,0 +1,15 @@
1import { UserRegistrationModel } from '@server/models/user/user-registration'
2import { PickWith } from '@shared/typescript-utils'
3import { MUserId } from './user'
4
5type Use<K extends keyof UserRegistrationModel, M> = PickWith<UserRegistrationModel, K, M>
6
7// ############################################################################
8
9export type MRegistration = Omit<UserRegistrationModel, 'User'>
10
11// ############################################################################
12
13export type MRegistrationFormattable =
14 MRegistration &
15 Use<'User', MUserId>
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
index 79c18c406..e10968c20 100644
--- a/server/types/plugins/register-server-auth.model.ts
+++ b/server/types/plugins/register-server-auth.model.ts
@@ -1,14 +1,33 @@
1import express from 'express' 1import express from 'express'
2import { UserRole } from '@shared/models' 2import { UserAdminFlag, UserRole } from '@shared/models'
3import { MOAuthToken, MUser } from '../models' 3import { MOAuthToken, MUser } from '../models'
4 4
5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions 5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
6 6
7export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
8
7export interface RegisterServerAuthenticatedResult { 9export interface RegisterServerAuthenticatedResult {
10 // Update the user profile if it already exists
11 // Default behaviour is no update
12 // Introduced in PeerTube >= 5.1
13 userUpdater?: <T> (options: {
14 fieldName: AuthenticatedResultUpdaterFieldName
15 currentValue: T
16 newValue: T
17 }) => T
18
8 username: string 19 username: string
9 email: string 20 email: string
10 role?: UserRole 21 role?: UserRole
11 displayName?: string 22 displayName?: string
23
24 // PeerTube >= 5.1
25 adminFlags?: UserAdminFlag
26
27 // PeerTube >= 5.1
28 videoQuota?: number
29 // PeerTube >= 5.1
30 videoQuotaDaily?: number
12} 31}
13 32
14export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { 33export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index 1e2bd830e..df419fff4 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -71,6 +71,9 @@ export type PeerTubeHelpers = {
71 config: { 71 config: {
72 getWebserverUrl: () => string 72 getWebserverUrl: () => string
73 73
74 // PeerTube >= 5.1
75 getServerListeningConfig: () => { hostname: string, port: number }
76
74 getServerConfig: () => Promise<ServerConfig> 77 getServerConfig: () => Promise<ServerConfig>
75 } 78 }
76 79