aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/users/index.ts3
-rw-r--r--server/controllers/api/users/my-notifications.ts15
-rw-r--r--server/helpers/custom-validators/activitypub/actor.ts4
-rw-r--r--server/helpers/regexp.ts23
-rw-r--r--server/initializers/migrations/0315-user-notifications.ts8
-rw-r--r--server/lib/activitypub/process/process-accept.ts2
-rw-r--r--server/lib/activitypub/process/process-follow.ts11
-rw-r--r--server/lib/emailer.ts63
-rw-r--r--server/lib/job-queue/handlers/activitypub-follow.ts9
-rw-r--r--server/lib/notifier.ts154
-rw-r--r--server/lib/user.ts13
-rw-r--r--server/models/account/account-blocklist.ts24
-rw-r--r--server/models/account/user-notification-setting.ts32
-rw-r--r--server/models/account/user-notification.ts95
-rw-r--r--server/models/account/user.ts51
-rw-r--r--server/models/video/video-comment.ts41
-rw-r--r--server/tests/api/check-params/user-notifications.ts5
-rw-r--r--server/tests/api/users/user-notifications.ts229
-rw-r--r--server/tests/helpers/comment-model.ts25
-rw-r--r--server/tests/helpers/index.ts1
20 files changed, 759 insertions, 49 deletions
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 98be46ea2..9e6a019f6 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -40,6 +40,7 @@ import { deleteUserToken } from '../../../lib/oauth-model'
40import { myBlocklistRouter } from './my-blocklist' 40import { myBlocklistRouter } from './my-blocklist'
41import { myVideosHistoryRouter } from './my-history' 41import { myVideosHistoryRouter } from './my-history'
42import { myNotificationsRouter } from './my-notifications' 42import { myNotificationsRouter } from './my-notifications'
43import { Notifier } from '../../../lib/notifier'
43 44
44const auditLogger = auditLoggerFactory('users') 45const auditLogger = auditLoggerFactory('users')
45 46
@@ -213,6 +214,8 @@ async function registerUser (req: express.Request, res: express.Response) {
213 await sendVerifyUserEmail(user) 214 await sendVerifyUserEmail(user)
214 } 215 }
215 216
217 Notifier.Instance.notifyOnNewUserRegistration(user)
218
216 return res.type('json').status(204).end() 219 return res.type('json').status(204).end()
217} 220}
218 221
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
index 4b81777a4..d74d26add 100644
--- a/server/controllers/api/users/my-notifications.ts
+++ b/server/controllers/api/users/my-notifications.ts
@@ -18,7 +18,7 @@ import {
18 markAsReadUserNotificationsValidator, 18 markAsReadUserNotificationsValidator,
19 updateNotificationSettingsValidator 19 updateNotificationSettingsValidator
20} from '../../../middlewares/validators/user-notifications' 20} from '../../../middlewares/validators/user-notifications'
21import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users' 21import { UserNotificationSetting } from '../../../../shared/models/users'
22import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' 22import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
23 23
24const myNotificationsRouter = express.Router() 24const myNotificationsRouter = express.Router()
@@ -53,7 +53,7 @@ export {
53 53
54async function updateNotificationSettings (req: express.Request, res: express.Response) { 54async function updateNotificationSettings (req: express.Request, res: express.Response) {
55 const user: UserModel = res.locals.oauth.token.User 55 const user: UserModel = res.locals.oauth.token.User
56 const body: UserNotificationSetting = req.body 56 const body = req.body
57 57
58 const query = { 58 const query = {
59 where: { 59 where: {
@@ -61,14 +61,19 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
61 } 61 }
62 } 62 }
63 63
64 await UserNotificationSettingModel.update({ 64 const values: UserNotificationSetting = {
65 newVideoFromSubscription: body.newVideoFromSubscription, 65 newVideoFromSubscription: body.newVideoFromSubscription,
66 newCommentOnMyVideo: body.newCommentOnMyVideo, 66 newCommentOnMyVideo: body.newCommentOnMyVideo,
67 videoAbuseAsModerator: body.videoAbuseAsModerator, 67 videoAbuseAsModerator: body.videoAbuseAsModerator,
68 blacklistOnMyVideo: body.blacklistOnMyVideo, 68 blacklistOnMyVideo: body.blacklistOnMyVideo,
69 myVideoPublished: body.myVideoPublished, 69 myVideoPublished: body.myVideoPublished,
70 myVideoImportFinished: body.myVideoImportFinished 70 myVideoImportFinished: body.myVideoImportFinished,
71 }, query) 71 newFollow: body.newFollow,
72 newUserRegistration: body.newUserRegistration,
73 commentMention: body.commentMention,
74 }
75
76 await UserNotificationSettingModel.update(values, query)
72 77
73 return res.status(204).end() 78 return res.status(204).end()
74} 79}
diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts
index 77c003cdf..070632a20 100644
--- a/server/helpers/custom-validators/activitypub/actor.ts
+++ b/server/helpers/custom-validators/activitypub/actor.ts
@@ -27,7 +27,8 @@ function isActorPublicKeyValid (publicKey: string) {
27 validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY) 27 validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY)
28} 28}
29 29
30const actorNameRegExp = new RegExp('^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_\.]+$') 30const actorNameAlphabet = '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_.]'
31const actorNameRegExp = new RegExp(`^${actorNameAlphabet}+$`)
31function isActorPreferredUsernameValid (preferredUsername: string) { 32function isActorPreferredUsernameValid (preferredUsername: string) {
32 return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp) 33 return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp)
33} 34}
@@ -127,6 +128,7 @@ function areValidActorHandles (handles: string[]) {
127 128
128export { 129export {
129 normalizeActor, 130 normalizeActor,
131 actorNameAlphabet,
130 areValidActorHandles, 132 areValidActorHandles,
131 isActorEndpointsObjectValid, 133 isActorEndpointsObjectValid,
132 isActorPublicKeyObjectValid, 134 isActorPublicKeyObjectValid,
diff --git a/server/helpers/regexp.ts b/server/helpers/regexp.ts
new file mode 100644
index 000000000..2336654b0
--- /dev/null
+++ b/server/helpers/regexp.ts
@@ -0,0 +1,23 @@
1// Thanks to https://regex101.com
2function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
3 let m: RegExpExecArray
4 let i = 0
5 let result: RegExpExecArray[] = []
6
7 // tslint:disable:no-conditional-assignment
8 while ((m = regex.exec(str)) !== null && i < maxIterations) {
9 // This is necessary to avoid infinite loops with zero-width matches
10 if (m.index === regex.lastIndex) {
11 regex.lastIndex++
12 }
13
14 result.push(m)
15 i++
16 }
17
18 return result
19}
20
21export {
22 regexpCapture
23}
diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts
index 8c54c5d6c..34f9fd193 100644
--- a/server/initializers/migrations/0315-user-notifications.ts
+++ b/server/initializers/migrations/0315-user-notifications.ts
@@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL,
15"blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL, 15"blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL,
16"myVideoPublished" INTEGER NOT NULL DEFAULT NULL, 16"myVideoPublished" INTEGER NOT NULL DEFAULT NULL,
17"myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL, 17"myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL,
18"newUserRegistration" INTEGER NOT NULL DEFAULT NULL,
19"newFollow" INTEGER NOT NULL DEFAULT NULL,
20"commentMention" INTEGER NOT NULL DEFAULT NULL,
18"userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 21"userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
19"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, 22"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
20"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, 23"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
@@ -26,8 +29,9 @@ PRIMARY KEY ("id"))
26 { 29 {
27 const query = 'INSERT INTO "userNotificationSetting" ' + 30 const query = 'INSERT INTO "userNotificationSetting" ' +
28 '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + 31 '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
29 '"myVideoPublished", "myVideoImportFinished", "userId", "createdAt", "updatedAt") ' + 32 '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
30 '(SELECT 2, 2, 4, 4, 2, 2, id, NOW(), NOW() FROM "user")' 33 '"userId", "createdAt", "updatedAt") ' +
34 '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")'
31 35
32 await utils.sequelize.query(query) 36 await utils.sequelize.query(query)
33 } 37 }
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts
index 89bda9c32..605705ad3 100644
--- a/server/lib/activitypub/process/process-accept.ts
+++ b/server/lib/activitypub/process/process-accept.ts
@@ -2,6 +2,7 @@ import { ActivityAccept } from '../../../../shared/models/activitypub'
2import { ActorModel } from '../../../models/activitypub/actor' 2import { ActorModel } from '../../../models/activitypub/actor'
3import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 3import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
4import { addFetchOutboxJob } from '../actor' 4import { addFetchOutboxJob } from '../actor'
5import { Notifier } from '../../notifier'
5 6
6async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) { 7async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
7 if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.') 8 if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
@@ -24,6 +25,7 @@ async function processAccept (actor: ActorModel, targetActor: ActorModel) {
24 if (follow.state !== 'accepted') { 25 if (follow.state !== 'accepted') {
25 follow.set('state', 'accepted') 26 follow.set('state', 'accepted')
26 await follow.save() 27 await follow.save()
28
27 await addFetchOutboxJob(targetActor) 29 await addFetchOutboxJob(targetActor)
28 } 30 }
29} 31}
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts
index 24c9085f7..a67892440 100644
--- a/server/lib/activitypub/process/process-follow.ts
+++ b/server/lib/activitypub/process/process-follow.ts
@@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/activitypub/actor'
6import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 6import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
7import { sendAccept } from '../send' 7import { sendAccept } from '../send'
8import { Notifier } from '../../notifier'
8 9
9async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { 10async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
10 const activityObject = activity.object 11 const activityObject = activity.object
@@ -21,13 +22,13 @@ export {
21// --------------------------------------------------------------------------- 22// ---------------------------------------------------------------------------
22 23
23async function processFollow (actor: ActorModel, targetActorURL: string) { 24async function processFollow (actor: ActorModel, targetActorURL: string) {
24 await sequelizeTypescript.transaction(async t => { 25 const { actorFollow, created } = await sequelizeTypescript.transaction(async t => {
25 const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t) 26 const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
26 27
27 if (!targetActor) throw new Error('Unknown actor') 28 if (!targetActor) throw new Error('Unknown actor')
28 if (targetActor.isOwned() === false) throw new Error('This is not a local actor.') 29 if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
29 30
30 const [ actorFollow ] = await ActorFollowModel.findOrCreate({ 31 const [ actorFollow, created ] = await ActorFollowModel.findOrCreate({
31 where: { 32 where: {
32 actorId: actor.id, 33 actorId: actor.id,
33 targetActorId: targetActor.id 34 targetActorId: targetActor.id
@@ -52,8 +53,12 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
52 actorFollow.ActorFollowing = targetActor 53 actorFollow.ActorFollowing = targetActor
53 54
54 // Target sends to actor he accepted the follow request 55 // Target sends to actor he accepted the follow request
55 return sendAccept(actorFollow) 56 await sendAccept(actorFollow)
57
58 return { actorFollow, created }
56 }) 59 })
57 60
61 if (created) Notifier.Instance.notifyOfNewFollow(actorFollow)
62
58 logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url) 63 logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url)
59} 64}
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 6dc8f2adf..3429498e7 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -11,6 +11,7 @@ import { VideoCommentModel } from '../models/video/video-comment'
11import { VideoAbuseModel } from '../models/video/video-abuse' 11import { VideoAbuseModel } from '../models/video/video-abuse'
12import { VideoBlacklistModel } from '../models/video/video-blacklist' 12import { VideoBlacklistModel } from '../models/video/video-blacklist'
13import { VideoImportModel } from '../models/video/video-import' 13import { VideoImportModel } from '../models/video/video-import'
14import { ActorFollowModel } from '../models/activitypub/actor-follow'
14 15
15class Emailer { 16class Emailer {
16 17
@@ -103,6 +104,25 @@ class Emailer {
103 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 104 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
104 } 105 }
105 106
107 addNewFollowNotification (to: string[], actorFollow: ActorFollowModel, followType: 'account' | 'channel') {
108 const followerName = actorFollow.ActorFollower.Account.getDisplayName()
109 const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
110
111 const text = `Hi dear user,\n\n` +
112 `Your ${followType} ${followingName} has a new subscriber: ${followerName}` +
113 `\n\n` +
114 `Cheers,\n` +
115 `PeerTube.`
116
117 const emailPayload: EmailPayload = {
118 to,
119 subject: 'New follower on your channel ' + followingName,
120 text
121 }
122
123 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
124 }
125
106 myVideoPublishedNotification (to: string[], video: VideoModel) { 126 myVideoPublishedNotification (to: string[], video: VideoModel) {
107 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() 127 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
108 128
@@ -185,7 +205,29 @@ class Emailer {
185 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 205 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
186 } 206 }
187 207
188 async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) { 208 addNewCommentMentionNotification (to: string[], comment: VideoCommentModel) {
209 const accountName = comment.Account.getDisplayName()
210 const video = comment.Video
211 const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
212
213 const text = `Hi dear user,\n\n` +
214 `${accountName} mentioned you on video ${video.name}` +
215 `\n\n` +
216 `You can view the comment on ${commentUrl} ` +
217 `\n\n` +
218 `Cheers,\n` +
219 `PeerTube.`
220
221 const emailPayload: EmailPayload = {
222 to,
223 subject: 'Mention on video ' + video.name,
224 text
225 }
226
227 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
228 }
229
230 addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
189 const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath() 231 const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
190 232
191 const text = `Hi,\n\n` + 233 const text = `Hi,\n\n` +
@@ -202,7 +244,22 @@ class Emailer {
202 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 244 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
203 } 245 }
204 246
205 async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) { 247 addNewUserRegistrationNotification (to: string[], user: UserModel) {
248 const text = `Hi,\n\n` +
249 `User ${user.username} just registered on ${CONFIG.WEBSERVER.HOST} PeerTube instance.\n\n` +
250 `Cheers,\n` +
251 `PeerTube.`
252
253 const emailPayload: EmailPayload = {
254 to,
255 subject: '[PeerTube] New user registration on ' + CONFIG.WEBSERVER.HOST,
256 text
257 }
258
259 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
260 }
261
262 addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
206 const videoName = videoBlacklist.Video.name 263 const videoName = videoBlacklist.Video.name
207 const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() 264 const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
208 265
@@ -224,7 +281,7 @@ class Emailer {
224 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 281 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
225 } 282 }
226 283
227 async addVideoUnblacklistNotification (to: string[], video: VideoModel) { 284 addVideoUnblacklistNotification (to: string[], video: VideoModel) {
228 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() 285 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
229 286
230 const text = 'Hi,\n\n' + 287 const text = 'Hi,\n\n' +
diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts
index 36d0f237b..b4d381062 100644
--- a/server/lib/job-queue/handlers/activitypub-follow.ts
+++ b/server/lib/job-queue/handlers/activitypub-follow.ts
@@ -8,6 +8,7 @@ import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor'
8import { retryTransactionWrapper } from '../../../helpers/database-utils' 8import { retryTransactionWrapper } from '../../../helpers/database-utils'
9import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 9import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
10import { ActorModel } from '../../../models/activitypub/actor' 10import { ActorModel } from '../../../models/activitypub/actor'
11import { Notifier } from '../../notifier'
11 12
12export type ActivitypubFollowPayload = { 13export type ActivitypubFollowPayload = {
13 followerActorId: number 14 followerActorId: number
@@ -42,7 +43,7 @@ export {
42 43
43// --------------------------------------------------------------------------- 44// ---------------------------------------------------------------------------
44 45
45function follow (fromActor: ActorModel, targetActor: ActorModel) { 46async function follow (fromActor: ActorModel, targetActor: ActorModel) {
46 if (fromActor.id === targetActor.id) { 47 if (fromActor.id === targetActor.id) {
47 throw new Error('Follower is the same than target actor.') 48 throw new Error('Follower is the same than target actor.')
48 } 49 }
@@ -50,7 +51,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
50 // Same server, direct accept 51 // Same server, direct accept
51 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending' 52 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
52 53
53 return sequelizeTypescript.transaction(async t => { 54 const actorFollow = await sequelizeTypescript.transaction(async t => {
54 const [ actorFollow ] = await ActorFollowModel.findOrCreate({ 55 const [ actorFollow ] = await ActorFollowModel.findOrCreate({
55 where: { 56 where: {
56 actorId: fromActor.id, 57 actorId: fromActor.id,
@@ -68,5 +69,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
68 69
69 // Send a notification to remote server if our follow is not already accepted 70 // Send a notification to remote server if our follow is not already accepted
70 if (actorFollow.state !== 'accepted') await sendFollow(actorFollow) 71 if (actorFollow.state !== 'accepted') await sendFollow(actorFollow)
72
73 return actorFollow
71 }) 74 })
75
76 if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewFollow(actorFollow)
72} 77}
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
index 11b0937e9..2c51d7101 100644
--- a/server/lib/notifier.ts
+++ b/server/lib/notifier.ts
@@ -13,6 +13,8 @@ import { VideoBlacklistModel } from '../models/video/video-blacklist'
13import * as Bluebird from 'bluebird' 13import * as Bluebird from 'bluebird'
14import { VideoImportModel } from '../models/video/video-import' 14import { VideoImportModel } from '../models/video/video-import'
15import { AccountBlocklistModel } from '../models/account/account-blocklist' 15import { AccountBlocklistModel } from '../models/account/account-blocklist'
16import { ActorFollowModel } from '../models/activitypub/actor-follow'
17import { AccountModel } from '../models/account/account'
16 18
17class Notifier { 19class Notifier {
18 20
@@ -38,7 +40,10 @@ class Notifier {
38 40
39 notifyOnNewComment (comment: VideoCommentModel): void { 41 notifyOnNewComment (comment: VideoCommentModel): void {
40 this.notifyVideoOwnerOfNewComment(comment) 42 this.notifyVideoOwnerOfNewComment(comment)
41 .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err })) 43 .catch(err => logger.error('Cannot notify video owner of new comment %s.', comment.url, { err }))
44
45 this.notifyOfCommentMention(comment)
46 .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
42 } 47 }
43 48
44 notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void { 49 notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
@@ -61,6 +66,23 @@ class Notifier {
61 .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err })) 66 .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
62 } 67 }
63 68
69 notifyOnNewUserRegistration (user: UserModel): void {
70 this.notifyModeratorsOfNewUserRegistration(user)
71 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
72 }
73
74 notifyOfNewFollow (actorFollow: ActorFollowModel): void {
75 this.notifyUserOfNewActorFollow(actorFollow)
76 .catch(err => {
77 logger.error(
78 'Cannot notify owner of channel %s of a new follow by %s.',
79 actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
80 actorFollow.ActorFollower.Account.getDisplayName(),
81 err
82 )
83 })
84 }
85
64 private async notifySubscribersOfNewVideo (video: VideoModel) { 86 private async notifySubscribersOfNewVideo (video: VideoModel) {
65 // List all followers that are users 87 // List all followers that are users
66 const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId) 88 const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
@@ -90,6 +112,8 @@ class Notifier {
90 } 112 }
91 113
92 private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) { 114 private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
115 if (comment.Video.isOwned() === false) return
116
93 const user = await UserModel.loadByVideoId(comment.videoId) 117 const user = await UserModel.loadByVideoId(comment.videoId)
94 118
95 // Not our user or user comments its own video 119 // Not our user or user comments its own video
@@ -122,11 +146,100 @@ class Notifier {
122 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) 146 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
123 } 147 }
124 148
125 private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) { 149 private async notifyOfCommentMention (comment: VideoCommentModel) {
126 const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES) 150 const usernames = comment.extractMentions()
151 let users = await UserModel.listByUsernames(usernames)
152
153 if (comment.Video.isOwned()) {
154 const userException = await UserModel.loadByVideoId(comment.videoId)
155 users = users.filter(u => u.id !== userException.id)
156 }
157
158 // Don't notify if I mentioned myself
159 users = users.filter(u => u.Account.id !== comment.accountId)
160
127 if (users.length === 0) return 161 if (users.length === 0) return
128 162
129 logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url) 163 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(users.map(u => u.Account.id), comment.accountId)
164
165 logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
166
167 function settingGetter (user: UserModel) {
168 if (accountMutedHash[user.Account.id] === true) return UserNotificationSettingValue.NONE
169
170 return user.NotificationSetting.commentMention
171 }
172
173 async function notificationCreator (user: UserModel) {
174 const notification = await UserNotificationModel.create({
175 type: UserNotificationType.COMMENT_MENTION,
176 userId: user.id,
177 commentId: comment.id
178 })
179 notification.Comment = comment
180
181 return notification
182 }
183
184 function emailSender (emails: string[]) {
185 return Emailer.Instance.addNewCommentMentionNotification(emails, comment)
186 }
187
188 return this.notify({ users, settingGetter, notificationCreator, emailSender })
189 }
190
191 private async notifyUserOfNewActorFollow (actorFollow: ActorFollowModel) {
192 if (actorFollow.ActorFollowing.isOwned() === false) return
193
194 // Account follows one of our account?
195 let followType: 'account' | 'channel' = 'channel'
196 let user = await UserModel.loadByChannelActorId(actorFollow.ActorFollowing.id)
197
198 // Account follows one of our channel?
199 if (!user) {
200 user = await UserModel.loadByAccountActorId(actorFollow.ActorFollowing.id)
201 followType = 'account'
202 }
203
204 if (!user) return
205
206 if (!actorFollow.ActorFollower.Account || !actorFollow.ActorFollower.Account.name) {
207 actorFollow.ActorFollower.Account = await actorFollow.ActorFollower.$get('Account') as AccountModel
208 }
209 const followerAccount = actorFollow.ActorFollower.Account
210
211 const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, followerAccount.id)
212 if (accountMuted) return
213
214 logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
215
216 function settingGetter (user: UserModel) {
217 return user.NotificationSetting.newFollow
218 }
219
220 async function notificationCreator (user: UserModel) {
221 const notification = await UserNotificationModel.create({
222 type: UserNotificationType.NEW_FOLLOW,
223 userId: user.id,
224 actorFollowId: actorFollow.id
225 })
226 notification.ActorFollow = actorFollow
227
228 return notification
229 }
230
231 function emailSender (emails: string[]) {
232 return Emailer.Instance.addNewFollowNotification(emails, actorFollow, followType)
233 }
234
235 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
236 }
237
238 private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
239 const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
240 if (moderators.length === 0) return
241
242 logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, videoAbuse.Video.url)
130 243
131 function settingGetter (user: UserModel) { 244 function settingGetter (user: UserModel) {
132 return user.NotificationSetting.videoAbuseAsModerator 245 return user.NotificationSetting.videoAbuseAsModerator
@@ -147,7 +260,7 @@ class Notifier {
147 return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse) 260 return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
148 } 261 }
149 262
150 return this.notify({ users, settingGetter, notificationCreator, emailSender }) 263 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
151 } 264 }
152 265
153 private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) { 266 private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
@@ -264,6 +377,37 @@ class Notifier {
264 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) 377 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
265 } 378 }
266 379
380 private async notifyModeratorsOfNewUserRegistration (registeredUser: UserModel) {
381 const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
382 if (moderators.length === 0) return
383
384 logger.info(
385 'Notifying %s moderators of new user registration of %s.',
386 moderators.length, registeredUser.Account.Actor.preferredUsername
387 )
388
389 function settingGetter (user: UserModel) {
390 return user.NotificationSetting.newUserRegistration
391 }
392
393 async function notificationCreator (user: UserModel) {
394 const notification = await UserNotificationModel.create({
395 type: UserNotificationType.NEW_USER_REGISTRATION,
396 userId: user.id,
397 accountId: registeredUser.Account.id
398 })
399 notification.Account = registeredUser.Account
400
401 return notification
402 }
403
404 function emailSender (emails: string[]) {
405 return Emailer.Instance.addNewUserRegistrationNotification(emails, registeredUser)
406 }
407
408 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
409 }
410
267 private async notify (options: { 411 private async notify (options: {
268 users: UserModel[], 412 users: UserModel[],
269 notificationCreator: (user: UserModel) => Promise<UserNotificationModel>, 413 notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 481571828..9e24e85a0 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -10,7 +10,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' 10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
11import { ActorModel } from '../models/activitypub/actor' 11import { ActorModel } from '../models/activitypub/actor'
12import { UserNotificationSettingModel } from '../models/account/user-notification-setting' 12import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
13import { UserNotificationSettingValue } from '../../shared/models/users' 13import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
14 14
15async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { 15async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
16 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { 16 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@@ -96,13 +96,18 @@ export {
96// --------------------------------------------------------------------------- 96// ---------------------------------------------------------------------------
97 97
98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { 98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
99 return UserNotificationSettingModel.create({ 99 const values: UserNotificationSetting & { userId: number } = {
100 userId: user.id, 100 userId: user.id,
101 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, 101 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
102 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, 102 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
103 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, 103 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
104 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, 104 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
105 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 105 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
106 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 106 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
107 }, { transaction: t }) 107 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION,
108 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
109 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION
110 }
111
112 return UserNotificationSettingModel.create(values, { transaction: t })
108} 113}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index 54ac290c4..efd6ed59e 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -2,6 +2,7 @@ import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, Updated
2import { AccountModel } from './account' 2import { AccountModel } from './account'
3import { getSort } from '../utils' 3import { getSort } from '../utils'
4import { AccountBlock } from '../../../shared/models/blocklist' 4import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize'
5 6
6enum ScopeNames { 7enum ScopeNames {
7 WITH_ACCOUNTS = 'WITH_ACCOUNTS' 8 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@@ -73,18 +74,33 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
73 BlockedAccount: AccountModel 74 BlockedAccount: AccountModel
74 75
75 static isAccountMutedBy (accountId: number, targetAccountId: number) { 76 static isAccountMutedBy (accountId: number, targetAccountId: number) {
77 return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId)
78 .then(result => result[accountId])
79 }
80
81 static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
76 const query = { 82 const query = {
77 attributes: [ 'id' ], 83 attributes: [ 'accountId', 'id' ],
78 where: { 84 where: {
79 accountId, 85 accountId: {
86 [Op.any]: accountIds
87 },
80 targetAccountId 88 targetAccountId
81 }, 89 },
82 raw: true 90 raw: true
83 } 91 }
84 92
85 return AccountBlocklistModel.unscoped() 93 return AccountBlocklistModel.unscoped()
86 .findOne(query) 94 .findAll(query)
87 .then(a => !!a) 95 .then(rows => {
96 const result: { [accountId: number]: boolean } = {}
97
98 for (const accountId of accountIds) {
99 result[accountId] = !!rows.find(r => r.accountId === accountId)
100 }
101
102 return result
103 })
88 } 104 }
89 105
90 static loadByAccountAndTarget (accountId: number, targetAccountId: number) { 106 static loadByAccountAndTarget (accountId: number, targetAccountId: number) {
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
index 6470defa7..f1c3ac223 100644
--- a/server/models/account/user-notification-setting.ts
+++ b/server/models/account/user-notification-setting.ts
@@ -83,6 +83,33 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
83 @Column 83 @Column
84 myVideoImportFinished: UserNotificationSettingValue 84 myVideoImportFinished: UserNotificationSettingValue
85 85
86 @AllowNull(false)
87 @Default(null)
88 @Is(
89 'UserNotificationSettingNewUserRegistration',
90 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
91 )
92 @Column
93 newUserRegistration: UserNotificationSettingValue
94
95 @AllowNull(false)
96 @Default(null)
97 @Is(
98 'UserNotificationSettingNewFollow',
99 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
100 )
101 @Column
102 newFollow: UserNotificationSettingValue
103
104 @AllowNull(false)
105 @Default(null)
106 @Is(
107 'UserNotificationSettingCommentMention',
108 value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
109 )
110 @Column
111 commentMention: UserNotificationSettingValue
112
86 @ForeignKey(() => UserModel) 113 @ForeignKey(() => UserModel)
87 @Column 114 @Column
88 userId: number 115 userId: number
@@ -114,7 +141,10 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
114 videoAbuseAsModerator: this.videoAbuseAsModerator, 141 videoAbuseAsModerator: this.videoAbuseAsModerator,
115 blacklistOnMyVideo: this.blacklistOnMyVideo, 142 blacklistOnMyVideo: this.blacklistOnMyVideo,
116 myVideoPublished: this.myVideoPublished, 143 myVideoPublished: this.myVideoPublished,
117 myVideoImportFinished: this.myVideoImportFinished 144 myVideoImportFinished: this.myVideoImportFinished,
145 newUserRegistration: this.newUserRegistration,
146 commentMention: this.commentMention,
147 newFollow: this.newFollow
118 } 148 }
119 } 149 }
120} 150}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 251244374..79afce600 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -25,6 +25,8 @@ import { AccountModel } from './account'
25import { VideoAbuseModel } from '../video/video-abuse' 25import { VideoAbuseModel } from '../video/video-abuse'
26import { VideoBlacklistModel } from '../video/video-blacklist' 26import { VideoBlacklistModel } from '../video/video-blacklist'
27import { VideoImportModel } from '../video/video-import' 27import { VideoImportModel } from '../video/video-import'
28import { ActorModel } from '../activitypub/actor'
29import { ActorFollowModel } from '../activitypub/actor-follow'
28 30
29enum ScopeNames { 31enum ScopeNames {
30 WITH_ALL = 'WITH_ALL' 32 WITH_ALL = 'WITH_ALL'
@@ -38,17 +40,17 @@ function buildVideoInclude (required: boolean) {
38 } 40 }
39} 41}
40 42
41function buildChannelInclude () { 43function buildChannelInclude (required: boolean) {
42 return { 44 return {
43 required: true, 45 required,
44 attributes: [ 'id', 'name' ], 46 attributes: [ 'id', 'name' ],
45 model: () => VideoChannelModel.unscoped() 47 model: () => VideoChannelModel.unscoped()
46 } 48 }
47} 49}
48 50
49function buildAccountInclude () { 51function buildAccountInclude (required: boolean) {
50 return { 52 return {
51 required: true, 53 required,
52 attributes: [ 'id', 'name' ], 54 attributes: [ 'id', 'name' ],
53 model: () => AccountModel.unscoped() 55 model: () => AccountModel.unscoped()
54 } 56 }
@@ -58,14 +60,14 @@ function buildAccountInclude () {
58 [ScopeNames.WITH_ALL]: { 60 [ScopeNames.WITH_ALL]: {
59 include: [ 61 include: [
60 Object.assign(buildVideoInclude(false), { 62 Object.assign(buildVideoInclude(false), {
61 include: [ buildChannelInclude() ] 63 include: [ buildChannelInclude(true) ]
62 }), 64 }),
63 { 65 {
64 attributes: [ 'id', 'originCommentId' ], 66 attributes: [ 'id', 'originCommentId' ],
65 model: () => VideoCommentModel.unscoped(), 67 model: () => VideoCommentModel.unscoped(),
66 required: false, 68 required: false,
67 include: [ 69 include: [
68 buildAccountInclude(), 70 buildAccountInclude(true),
69 buildVideoInclude(true) 71 buildVideoInclude(true)
70 ] 72 ]
71 }, 73 },
@@ -86,6 +88,42 @@ function buildAccountInclude () {
86 model: () => VideoImportModel.unscoped(), 88 model: () => VideoImportModel.unscoped(),
87 required: false, 89 required: false,
88 include: [ buildVideoInclude(false) ] 90 include: [ buildVideoInclude(false) ]
91 },
92 {
93 attributes: [ 'id', 'name' ],
94 model: () => AccountModel.unscoped(),
95 required: false,
96 include: [
97 {
98 attributes: [ 'id', 'preferredUsername' ],
99 model: () => ActorModel.unscoped(),
100 required: true
101 }
102 ]
103 },
104 {
105 attributes: [ 'id' ],
106 model: () => ActorFollowModel.unscoped(),
107 required: false,
108 include: [
109 {
110 attributes: [ 'preferredUsername' ],
111 model: () => ActorModel.unscoped(),
112 required: true,
113 as: 'ActorFollower',
114 include: [ buildAccountInclude(true) ]
115 },
116 {
117 attributes: [ 'preferredUsername' ],
118 model: () => ActorModel.unscoped(),
119 required: true,
120 as: 'ActorFollowing',
121 include: [
122 buildChannelInclude(false),
123 buildAccountInclude(false)
124 ]
125 }
126 ]
89 } 127 }
90 ] 128 ]
91 } 129 }
@@ -193,6 +231,30 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
193 }) 231 })
194 VideoImport: VideoImportModel 232 VideoImport: VideoImportModel
195 233
234 @ForeignKey(() => AccountModel)
235 @Column
236 accountId: number
237
238 @BelongsTo(() => AccountModel, {
239 foreignKey: {
240 allowNull: true
241 },
242 onDelete: 'cascade'
243 })
244 Account: AccountModel
245
246 @ForeignKey(() => ActorFollowModel)
247 @Column
248 actorFollowId: number
249
250 @BelongsTo(() => ActorFollowModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'cascade'
255 })
256 ActorFollow: ActorFollowModel
257
196 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { 258 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
197 const query: IFindOptions<UserNotificationModel> = { 259 const query: IFindOptions<UserNotificationModel> = {
198 offset: start, 260 offset: start,
@@ -264,6 +326,25 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
264 video: this.formatVideo(this.VideoBlacklist.Video) 326 video: this.formatVideo(this.VideoBlacklist.Video)
265 } : undefined 327 } : undefined
266 328
329 const account = this.Account ? {
330 id: this.Account.id,
331 displayName: this.Account.getDisplayName(),
332 name: this.Account.Actor.preferredUsername
333 } : undefined
334
335 const actorFollow = this.ActorFollow ? {
336 id: this.ActorFollow.id,
337 follower: {
338 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
339 name: this.ActorFollow.ActorFollower.preferredUsername
340 },
341 following: {
342 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
343 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
344 name: this.ActorFollow.ActorFollowing.preferredUsername
345 }
346 } : undefined
347
267 return { 348 return {
268 id: this.id, 349 id: this.id,
269 type: this.type, 350 type: this.type,
@@ -273,6 +354,8 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
273 comment, 354 comment,
274 videoAbuse, 355 videoAbuse,
275 videoBlacklist, 356 videoBlacklist,
357 account,
358 actorFollow,
276 createdAt: this.createdAt.toISOString(), 359 createdAt: this.createdAt.toISOString(),
277 updatedAt: this.updatedAt.toISOString() 360 updatedAt: this.updatedAt.toISOString()
278 } 361 }
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 33f56f641..017a96657 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -330,6 +330,16 @@ export class UserModel extends Model<UserModel> {
330 return UserModel.unscoped().findAll(query) 330 return UserModel.unscoped().findAll(query)
331 } 331 }
332 332
333 static listByUsernames (usernames: string[]) {
334 const query = {
335 where: {
336 username: usernames
337 }
338 }
339
340 return UserModel.findAll(query)
341 }
342
333 static loadById (id: number) { 343 static loadById (id: number) {
334 return UserModel.findById(id) 344 return UserModel.findById(id)
335 } 345 }
@@ -424,6 +434,47 @@ export class UserModel extends Model<UserModel> {
424 return UserModel.findOne(query) 434 return UserModel.findOne(query)
425 } 435 }
426 436
437 static loadByChannelActorId (videoChannelActorId: number) {
438 const query = {
439 include: [
440 {
441 required: true,
442 attributes: [ 'id' ],
443 model: AccountModel.unscoped(),
444 include: [
445 {
446 required: true,
447 attributes: [ 'id' ],
448 model: VideoChannelModel.unscoped(),
449 where: {
450 actorId: videoChannelActorId
451 }
452 }
453 ]
454 }
455 ]
456 }
457
458 return UserModel.findOne(query)
459 }
460
461 static loadByAccountActorId (accountActorId: number) {
462 const query = {
463 include: [
464 {
465 required: true,
466 attributes: [ 'id' ],
467 model: AccountModel.unscoped(),
468 where: {
469 actorId: accountActorId
470 }
471 }
472 ]
473 }
474
475 return UserModel.findOne(query)
476 }
477
427 static getOriginalVideoFileTotalFromUser (user: UserModel) { 478 static getOriginalVideoFileTotalFromUser (user: UserModel) {
428 // Don't use sequelize because we need to use a sub query 479 // Don't use sequelize because we need to use a sub query
429 const query = UserModel.generateUserQuotaBaseSQL() 480 const query = UserModel.generateUserQuotaBaseSQL()
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index d8fc2a564..cf6278da7 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -18,7 +18,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co
18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
19import { VideoComment } from '../../../shared/models/videos/video-comment.model' 19import { VideoComment } from '../../../shared/models/videos/video-comment.model'
20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21import { CONSTRAINTS_FIELDS } from '../../initializers' 21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
22import { sendDeleteVideoComment } from '../../lib/activitypub/send' 22import { sendDeleteVideoComment } from '../../lib/activitypub/send'
23import { AccountModel } from '../account/account' 23import { AccountModel } from '../account/account'
24import { ActorModel } from '../activitypub/actor' 24import { ActorModel } from '../activitypub/actor'
@@ -29,6 +29,9 @@ import { VideoModel } from './video'
29import { VideoChannelModel } from './video-channel' 29import { VideoChannelModel } from './video-channel'
30import { getServerActor } from '../../helpers/utils' 30import { getServerActor } from '../../helpers/utils'
31import { UserModel } from '../account/user' 31import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash'
32 35
33enum ScopeNames { 36enum ScopeNames {
34 WITH_ACCOUNT = 'WITH_ACCOUNT', 37 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -370,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
370 id: { 373 id: {
371 [ Sequelize.Op.in ]: Sequelize.literal('(' + 374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
372 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
373 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + 376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
374 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + 377 'UNION ' +
375 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + 378 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
379 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
380 ') ' +
376 'SELECT id FROM children' + 381 'SELECT id FROM children' +
377 ')'), 382 ')'),
378 [ Sequelize.Op.ne ]: comment.id 383 [ Sequelize.Op.ne ]: comment.id
@@ -460,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
460 return this.Account.isOwned() 465 return this.Account.isOwned()
461 } 466 }
462 467
468 extractMentions () {
469 if (!this.text) return []
470
471 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
473
474 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
475 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
476 const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
477 const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
478
479 return uniq(
480 [].concat(
481 regexpCapture(this.text, remoteMentionsRegex)
482 .map(([ , username ]) => username),
483
484 regexpCapture(this.text, localMentionsRegex)
485 .map(([ , username ]) => username),
486
487 regexpCapture(this.text, firstMentionRegex)
488 .map(([ , username1, username2 ]) => username1 || username2),
489
490 regexpCapture(this.text, endMentionRegex)
491 .map(([ , username1, username2 ]) => username1 || username2)
492 )
493 )
494 }
495
463 toFormattedJSON () { 496 toFormattedJSON () {
464 return { 497 return {
465 id: this.id, 498 id: this.id,
diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts
index 4f21f7b95..635f5c9a3 100644
--- a/server/tests/api/check-params/user-notifications.ts
+++ b/server/tests/api/check-params/user-notifications.ts
@@ -139,7 +139,10 @@ describe('Test user notifications API validators', function () {
139 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, 139 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
140 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, 140 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
141 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, 141 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
142 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION 142 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
143 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
144 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION,
145 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION
143 } 146 }
144 147
145 it('Should fail with missing fields', async function () { 148 it('Should fail with missing fields', async function () {
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
index e4966dbf5..ae77b4db2 100644
--- a/server/tests/api/users/user-notifications.ts
+++ b/server/tests/api/users/user-notifications.ts
@@ -10,9 +10,12 @@ import {
10 flushTests, 10 flushTests,
11 getMyUserInformation, 11 getMyUserInformation,
12 immutableAssign, 12 immutableAssign,
13 registerUser,
13 removeVideoFromBlacklist, 14 removeVideoFromBlacklist,
14 reportVideoAbuse, 15 reportVideoAbuse,
16 updateMyUser,
15 updateVideo, 17 updateVideo,
18 updateVideoChannel,
16 userLogin, 19 userLogin,
17 wait 20 wait
18} from '../../../../shared/utils' 21} from '../../../../shared/utils'
@@ -21,16 +24,20 @@ import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
21import { waitJobs } from '../../../../shared/utils/server/jobs' 24import { waitJobs } from '../../../../shared/utils/server/jobs'
22import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io' 25import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
23import { 26import {
27 checkCommentMention,
24 CheckerBaseParams, 28 CheckerBaseParams,
29 checkMyVideoImportIsFinished,
30 checkNewActorFollow,
25 checkNewBlacklistOnMyVideo, 31 checkNewBlacklistOnMyVideo,
26 checkNewCommentOnMyVideo, 32 checkNewCommentOnMyVideo,
27 checkNewVideoAbuseForModerators, 33 checkNewVideoAbuseForModerators,
28 checkNewVideoFromSubscription, 34 checkNewVideoFromSubscription,
35 checkUserRegistered,
36 checkVideoIsPublished,
29 getLastNotification, 37 getLastNotification,
30 getUserNotifications, 38 getUserNotifications,
31 markAsReadNotifications, 39 markAsReadNotifications,
32 updateMyNotificationSettings, 40 updateMyNotificationSettings
33 checkVideoIsPublished, checkMyVideoImportIsFinished
34} from '../../../../shared/utils/users/user-notifications' 41} from '../../../../shared/utils/users/user-notifications'
35import { 42import {
36 User, 43 User,
@@ -40,9 +47,9 @@ import {
40 UserNotificationType 47 UserNotificationType
41} from '../../../../shared/models/users' 48} from '../../../../shared/models/users'
42import { MockSmtpServer } from '../../../../shared/utils/miscs/email' 49import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
43import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions' 50import { addUserSubscription, removeUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
44import { VideoPrivacy } from '../../../../shared/models/videos' 51import { VideoPrivacy } from '../../../../shared/models/videos'
45import { getYoutubeVideoUrl, importVideo, getBadVideoUrl } from '../../../../shared/utils/videos/video-imports' 52import { getBadVideoUrl, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
46import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments' 53import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
47import * as uuidv4 from 'uuid/v4' 54import * as uuidv4 from 'uuid/v4'
48import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist' 55import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist'
@@ -81,12 +88,15 @@ describe('Test users notifications', function () {
81 let channelId: number 88 let channelId: number
82 89
83 const allNotificationSettings: UserNotificationSetting = { 90 const allNotificationSettings: UserNotificationSetting = {
84 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
85 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
86 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
87 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 91 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
92 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
88 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, 93 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
89 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL 94 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
95 myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
96 myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
97 commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
98 newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
99 newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
90 } 100 }
91 101
92 before(async function () { 102 before(async function () {
@@ -424,6 +434,114 @@ describe('Test users notifications', function () {
424 }) 434 })
425 }) 435 })
426 436
437 describe('Mention notifications', function () {
438 let baseParams: CheckerBaseParams
439
440 before(async () => {
441 baseParams = {
442 server: servers[0],
443 emails,
444 socketNotifications: userNotifications,
445 token: userAccessToken
446 }
447
448 await updateMyUser({
449 url: servers[0].url,
450 accessToken: servers[0].accessToken,
451 displayName: 'super root name'
452 })
453
454 await updateMyUser({
455 url: servers[1].url,
456 accessToken: servers[1].accessToken,
457 displayName: 'super root 2 name'
458 })
459 })
460
461 it('Should not send a new mention comment notification if I mention the video owner', async function () {
462 this.timeout(10000)
463
464 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
465 const uuid = resVideo.body.video.uuid
466
467 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
468 const commentId = resComment.body.comment.id
469
470 await wait(500)
471 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
472 })
473
474 it('Should not send a new mention comment notification if I mention myself', async function () {
475 this.timeout(10000)
476
477 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
478 const uuid = resVideo.body.video.uuid
479
480 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, '@user_1 hello')
481 const commentId = resComment.body.comment.id
482
483 await wait(500)
484 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
485 })
486
487 it('Should not send a new mention notification if the account is muted', async function () {
488 this.timeout(10000)
489
490 await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
491
492 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
493 const uuid = resVideo.body.video.uuid
494
495 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello')
496 const commentId = resComment.body.comment.id
497
498 await wait(500)
499 await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
500
501 await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
502 })
503
504 it('Should send a new mention notification after local comments', async function () {
505 this.timeout(10000)
506
507 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
508 const uuid = resVideo.body.video.uuid
509
510 const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello 1')
511 const threadId = resThread.body.comment.id
512
513 await wait(500)
514 await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root name', 'presence')
515
516 const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'hello 2 @user_1')
517 const commentId = resComment.body.comment.id
518
519 await wait(500)
520 await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root name', 'presence')
521 })
522
523 it('Should send a new mention notification after remote comments', async function () {
524 this.timeout(20000)
525
526 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
527 const uuid = resVideo.body.video.uuid
528
529 await waitJobs(servers)
530 const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'hello @user_1@localhost:9001 1')
531 const threadId = resThread.body.comment.id
532
533 await waitJobs(servers)
534 await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root 2 name', 'presence')
535
536 const text = '@user_1@localhost:9001 hello 2 @root@localhost:9001'
537 const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, text)
538 const commentId = resComment.body.comment.id
539
540 await waitJobs(servers)
541 await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root 2 name', 'presence')
542 })
543 })
544
427 describe('Video abuse for moderators notification' , function () { 545 describe('Video abuse for moderators notification' , function () {
428 let baseParams: CheckerBaseParams 546 let baseParams: CheckerBaseParams
429 547
@@ -645,6 +763,101 @@ describe('Test users notifications', function () {
645 }) 763 })
646 }) 764 })
647 765
766 describe('New registration', function () {
767 let baseParams: CheckerBaseParams
768
769 before(() => {
770 baseParams = {
771 server: servers[0],
772 emails,
773 socketNotifications: adminNotifications,
774 token: servers[0].accessToken
775 }
776 })
777
778 it('Should send a notification only to moderators when a user registers on the instance', async function () {
779 await registerUser(servers[0].url, 'user_45', 'password')
780
781 await waitJobs(servers)
782
783 await checkUserRegistered(baseParams, 'user_45', 'presence')
784
785 const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
786 await checkUserRegistered(immutableAssign(baseParams, userOverride), 'user_45', 'absence')
787 })
788 })
789
790 describe('New actor follow', function () {
791 let baseParams: CheckerBaseParams
792 let myChannelName = 'super channel name'
793 let myUserName = 'super user name'
794
795 before(async () => {
796 baseParams = {
797 server: servers[0],
798 emails,
799 socketNotifications: userNotifications,
800 token: userAccessToken
801 }
802
803 await updateMyUser({
804 url: servers[0].url,
805 accessToken: servers[0].accessToken,
806 displayName: 'super root name'
807 })
808
809 await updateMyUser({
810 url: servers[0].url,
811 accessToken: userAccessToken,
812 displayName: myUserName
813 })
814
815 await updateMyUser({
816 url: servers[1].url,
817 accessToken: servers[1].accessToken,
818 displayName: 'super root 2 name'
819 })
820
821 await updateVideoChannel(servers[0].url, userAccessToken, 'user_1_channel', { displayName: myChannelName })
822 })
823
824 it('Should notify when a local channel is following one of our channel', async function () {
825 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
826
827 await waitJobs(servers)
828
829 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
830
831 await removeUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
832 })
833
834 it('Should notify when a remote channel is following one of our channel', async function () {
835 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
836
837 await waitJobs(servers)
838
839 await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
840
841 await removeUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
842 })
843
844 it('Should notify when a local account is following one of our channel', async function () {
845 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001')
846
847 await waitJobs(servers)
848
849 await checkNewActorFollow(baseParams, 'account', 'root', 'super root name', myUserName, 'presence')
850 })
851
852 it('Should notify when a remote account is following one of our channel', async function () {
853 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001')
854
855 await waitJobs(servers)
856
857 await checkNewActorFollow(baseParams, 'account', 'root', 'super root 2 name', myUserName, 'presence')
858 })
859 })
860
648 describe('Mark as read', function () { 861 describe('Mark as read', function () {
649 it('Should mark as read some notifications', async function () { 862 it('Should mark as read some notifications', async function () {
650 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3) 863 const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3)
diff --git a/server/tests/helpers/comment-model.ts b/server/tests/helpers/comment-model.ts
new file mode 100644
index 000000000..76bb0f212
--- /dev/null
+++ b/server/tests/helpers/comment-model.ts
@@ -0,0 +1,25 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { VideoCommentModel } from '../../models/video/video-comment'
6
7const expect = chai.expect
8
9class CommentMock {
10 text: string
11
12 extractMentions = VideoCommentModel.prototype.extractMentions
13}
14
15describe('Comment model', function () {
16 it('Should correctly extract mentions', async function () {
17 const comment = new CommentMock()
18
19 comment.text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' +
20 'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end'
21 const result = comment.extractMentions().sort()
22
23 expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ])
24 })
25})
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
index 40c7dc70e..551208245 100644
--- a/server/tests/helpers/index.ts
+++ b/server/tests/helpers/index.ts
@@ -1 +1,2 @@
1import './core-utils' 1import './core-utils'
2import './comment-model'