diff options
25 files changed, 899 insertions, 57 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' | |||
40 | import { myBlocklistRouter } from './my-blocklist' | 40 | import { myBlocklistRouter } from './my-blocklist' |
41 | import { myVideosHistoryRouter } from './my-history' | 41 | import { myVideosHistoryRouter } from './my-history' |
42 | import { myNotificationsRouter } from './my-notifications' | 42 | import { myNotificationsRouter } from './my-notifications' |
43 | import { Notifier } from '../../../lib/notifier' | ||
43 | 44 | ||
44 | const auditLogger = auditLoggerFactory('users') | 45 | const 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' |
21 | import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users' | 21 | import { UserNotificationSetting } from '../../../../shared/models/users' |
22 | import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' | 22 | import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' |
23 | 23 | ||
24 | const myNotificationsRouter = express.Router() | 24 | const myNotificationsRouter = express.Router() |
@@ -53,7 +53,7 @@ export { | |||
53 | 53 | ||
54 | async function updateNotificationSettings (req: express.Request, res: express.Response) { | 54 | async 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 | ||
30 | const actorNameRegExp = new RegExp('^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_\.]+$') | 30 | const actorNameAlphabet = '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_.]' |
31 | const actorNameRegExp = new RegExp(`^${actorNameAlphabet}+$`) | ||
31 | function isActorPreferredUsernameValid (preferredUsername: string) { | 32 | function 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 | ||
128 | export { | 129 | export { |
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 | ||
2 | function 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 | |||
21 | export { | ||
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' | |||
2 | import { ActorModel } from '../../../models/activitypub/actor' | 2 | import { ActorModel } from '../../../models/activitypub/actor' |
3 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' | 3 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' |
4 | import { addFetchOutboxJob } from '../actor' | 4 | import { addFetchOutboxJob } from '../actor' |
5 | import { Notifier } from '../../notifier' | ||
5 | 6 | ||
6 | async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) { | 7 | async 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' | |||
5 | import { ActorModel } from '../../../models/activitypub/actor' | 5 | import { ActorModel } from '../../../models/activitypub/actor' |
6 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' | 6 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' |
7 | import { sendAccept } from '../send' | 7 | import { sendAccept } from '../send' |
8 | import { Notifier } from '../../notifier' | ||
8 | 9 | ||
9 | async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { | 10 | async 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 | ||
23 | async function processFollow (actor: ActorModel, targetActorURL: string) { | 24 | async 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' | |||
11 | import { VideoAbuseModel } from '../models/video/video-abuse' | 11 | import { VideoAbuseModel } from '../models/video/video-abuse' |
12 | import { VideoBlacklistModel } from '../models/video/video-blacklist' | 12 | import { VideoBlacklistModel } from '../models/video/video-blacklist' |
13 | import { VideoImportModel } from '../models/video/video-import' | 13 | import { VideoImportModel } from '../models/video/video-import' |
14 | import { ActorFollowModel } from '../models/activitypub/actor-follow' | ||
14 | 15 | ||
15 | class Emailer { | 16 | class 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' | |||
8 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | 8 | import { retryTransactionWrapper } from '../../../helpers/database-utils' |
9 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' | 9 | import { ActorFollowModel } from '../../../models/activitypub/actor-follow' |
10 | import { ActorModel } from '../../../models/activitypub/actor' | 10 | import { ActorModel } from '../../../models/activitypub/actor' |
11 | import { Notifier } from '../../notifier' | ||
11 | 12 | ||
12 | export type ActivitypubFollowPayload = { | 13 | export type ActivitypubFollowPayload = { |
13 | followerActorId: number | 14 | followerActorId: number |
@@ -42,7 +43,7 @@ export { | |||
42 | 43 | ||
43 | // --------------------------------------------------------------------------- | 44 | // --------------------------------------------------------------------------- |
44 | 45 | ||
45 | function follow (fromActor: ActorModel, targetActor: ActorModel) { | 46 | async 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' | |||
13 | import * as Bluebird from 'bluebird' | 13 | import * as Bluebird from 'bluebird' |
14 | import { VideoImportModel } from '../models/video/video-import' | 14 | import { VideoImportModel } from '../models/video/video-import' |
15 | import { AccountBlocklistModel } from '../models/account/account-blocklist' | 15 | import { AccountBlocklistModel } from '../models/account/account-blocklist' |
16 | import { ActorFollowModel } from '../models/activitypub/actor-follow' | ||
17 | import { AccountModel } from '../models/account/account' | ||
16 | 18 | ||
17 | class Notifier { | 19 | class 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' | |||
10 | import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' | 10 | import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' |
11 | import { ActorModel } from '../models/activitypub/actor' | 11 | import { ActorModel } from '../models/activitypub/actor' |
12 | import { UserNotificationSettingModel } from '../models/account/user-notification-setting' | 12 | import { UserNotificationSettingModel } from '../models/account/user-notification-setting' |
13 | import { UserNotificationSettingValue } from '../../shared/models/users' | 13 | import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' |
14 | 14 | ||
15 | async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { | 15 | async 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 | ||
98 | function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { | 98 | function 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 | |||
2 | import { AccountModel } from './account' | 2 | import { AccountModel } from './account' |
3 | import { getSort } from '../utils' | 3 | import { getSort } from '../utils' |
4 | import { AccountBlock } from '../../../shared/models/blocklist' | 4 | import { AccountBlock } from '../../../shared/models/blocklist' |
5 | import { Op } from 'sequelize' | ||
5 | 6 | ||
6 | enum ScopeNames { | 7 | enum 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' | |||
25 | import { VideoAbuseModel } from '../video/video-abuse' | 25 | import { VideoAbuseModel } from '../video/video-abuse' |
26 | import { VideoBlacklistModel } from '../video/video-blacklist' | 26 | import { VideoBlacklistModel } from '../video/video-blacklist' |
27 | import { VideoImportModel } from '../video/video-import' | 27 | import { VideoImportModel } from '../video/video-import' |
28 | import { ActorModel } from '../activitypub/actor' | ||
29 | import { ActorFollowModel } from '../activitypub/actor-follow' | ||
28 | 30 | ||
29 | enum ScopeNames { | 31 | enum 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 | ||
41 | function buildChannelInclude () { | 43 | function 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 | ||
49 | function buildAccountInclude () { | 51 | function 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 | |||
18 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | 18 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' |
19 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' | 19 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' |
20 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | 20 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' |
21 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 21 | import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' |
22 | import { sendDeleteVideoComment } from '../../lib/activitypub/send' | 22 | import { sendDeleteVideoComment } from '../../lib/activitypub/send' |
23 | import { AccountModel } from '../account/account' | 23 | import { AccountModel } from '../account/account' |
24 | import { ActorModel } from '../activitypub/actor' | 24 | import { ActorModel } from '../activitypub/actor' |
@@ -29,6 +29,9 @@ import { VideoModel } from './video' | |||
29 | import { VideoChannelModel } from './video-channel' | 29 | import { VideoChannelModel } from './video-channel' |
30 | import { getServerActor } from '../../helpers/utils' | 30 | import { getServerActor } from '../../helpers/utils' |
31 | import { UserModel } from '../account/user' | 31 | import { UserModel } from '../account/user' |
32 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | ||
33 | import { regexpCapture } from '../../helpers/regexp' | ||
34 | import { uniq } from 'lodash' | ||
32 | 35 | ||
33 | enum ScopeNames { | 36 | enum 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' | |||
21 | import { waitJobs } from '../../../../shared/utils/server/jobs' | 24 | import { waitJobs } from '../../../../shared/utils/server/jobs' |
22 | import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io' | 25 | import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io' |
23 | import { | 26 | import { |
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' |
35 | import { | 42 | import { |
36 | User, | 43 | User, |
@@ -40,9 +47,9 @@ import { | |||
40 | UserNotificationType | 47 | UserNotificationType |
41 | } from '../../../../shared/models/users' | 48 | } from '../../../../shared/models/users' |
42 | import { MockSmtpServer } from '../../../../shared/utils/miscs/email' | 49 | import { MockSmtpServer } from '../../../../shared/utils/miscs/email' |
43 | import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions' | 50 | import { addUserSubscription, removeUserSubscription } from '../../../../shared/utils/users/user-subscriptions' |
44 | import { VideoPrivacy } from '../../../../shared/models/videos' | 51 | import { VideoPrivacy } from '../../../../shared/models/videos' |
45 | import { getYoutubeVideoUrl, importVideo, getBadVideoUrl } from '../../../../shared/utils/videos/video-imports' | 52 | import { getBadVideoUrl, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports' |
46 | import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments' | 53 | import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments' |
47 | import * as uuidv4 from 'uuid/v4' | 54 | import * as uuidv4 from 'uuid/v4' |
48 | import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist' | 55 | import { 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 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { VideoCommentModel } from '../../models/video/video-comment' | ||
6 | |||
7 | const expect = chai.expect | ||
8 | |||
9 | class CommentMock { | ||
10 | text: string | ||
11 | |||
12 | extractMentions = VideoCommentModel.prototype.extractMentions | ||
13 | } | ||
14 | |||
15 | describe('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 @@ | |||
1 | import './core-utils' | 1 | import './core-utils' |
2 | import './comment-model' | ||
diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts index 55d351abf..f580e827e 100644 --- a/shared/models/users/user-notification-setting.model.ts +++ b/shared/models/users/user-notification-setting.model.ts | |||
@@ -12,4 +12,7 @@ export interface UserNotificationSetting { | |||
12 | blacklistOnMyVideo: UserNotificationSettingValue | 12 | blacklistOnMyVideo: UserNotificationSettingValue |
13 | myVideoPublished: UserNotificationSettingValue | 13 | myVideoPublished: UserNotificationSettingValue |
14 | myVideoImportFinished: UserNotificationSettingValue | 14 | myVideoImportFinished: UserNotificationSettingValue |
15 | newUserRegistration: UserNotificationSettingValue | ||
16 | newFollow: UserNotificationSettingValue | ||
17 | commentMention: UserNotificationSettingValue | ||
15 | } | 18 | } |
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts index ee9ac275a..9dd4f099f 100644 --- a/shared/models/users/user-notification.model.ts +++ b/shared/models/users/user-notification.model.ts | |||
@@ -6,7 +6,10 @@ export enum UserNotificationType { | |||
6 | UNBLACKLIST_ON_MY_VIDEO = 5, | 6 | UNBLACKLIST_ON_MY_VIDEO = 5, |
7 | MY_VIDEO_PUBLISHED = 6, | 7 | MY_VIDEO_PUBLISHED = 6, |
8 | MY_VIDEO_IMPORT_SUCCESS = 7, | 8 | MY_VIDEO_IMPORT_SUCCESS = 7, |
9 | MY_VIDEO_IMPORT_ERROR = 8 | 9 | MY_VIDEO_IMPORT_ERROR = 8, |
10 | NEW_USER_REGISTRATION = 9, | ||
11 | NEW_FOLLOW = 10, | ||
12 | COMMENT_MENTION = 11 | ||
10 | } | 13 | } |
11 | 14 | ||
12 | export interface VideoInfo { | 15 | export interface VideoInfo { |
@@ -55,6 +58,25 @@ export interface UserNotification { | |||
55 | video: VideoInfo | 58 | video: VideoInfo |
56 | } | 59 | } |
57 | 60 | ||
61 | account?: { | ||
62 | id: number | ||
63 | displayName: string | ||
64 | name: string | ||
65 | } | ||
66 | |||
67 | actorFollow?: { | ||
68 | id: number | ||
69 | follower: { | ||
70 | name: string | ||
71 | displayName: string | ||
72 | } | ||
73 | following: { | ||
74 | type: 'account' | 'channel' | ||
75 | name: string | ||
76 | displayName: string | ||
77 | } | ||
78 | } | ||
79 | |||
58 | createdAt: string | 80 | createdAt: string |
59 | updatedAt: string | 81 | updatedAt: string |
60 | } | 82 | } |
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 51c59d20a..090256bca 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts | |||
@@ -2,10 +2,15 @@ export enum UserRight { | |||
2 | ALL, | 2 | ALL, |
3 | 3 | ||
4 | MANAGE_USERS, | 4 | MANAGE_USERS, |
5 | |||
5 | MANAGE_SERVER_FOLLOW, | 6 | MANAGE_SERVER_FOLLOW, |
7 | |||
6 | MANAGE_SERVER_REDUNDANCY, | 8 | MANAGE_SERVER_REDUNDANCY, |
9 | |||
7 | MANAGE_VIDEO_ABUSES, | 10 | MANAGE_VIDEO_ABUSES, |
11 | |||
8 | MANAGE_JOBS, | 12 | MANAGE_JOBS, |
13 | |||
9 | MANAGE_CONFIGURATION, | 14 | MANAGE_CONFIGURATION, |
10 | 15 | ||
11 | MANAGE_ACCOUNTS_BLOCKLIST, | 16 | MANAGE_ACCOUNTS_BLOCKLIST, |
diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index adef8fd95..59c2ba106 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts | |||
@@ -29,7 +29,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = { | |||
29 | UserRight.UPDATE_ANY_VIDEO, | 29 | UserRight.UPDATE_ANY_VIDEO, |
30 | UserRight.SEE_ALL_VIDEOS, | 30 | UserRight.SEE_ALL_VIDEOS, |
31 | UserRight.MANAGE_ACCOUNTS_BLOCKLIST, | 31 | UserRight.MANAGE_ACCOUNTS_BLOCKLIST, |
32 | UserRight.MANAGE_SERVERS_BLOCKLIST | 32 | UserRight.MANAGE_SERVERS_BLOCKLIST, |
33 | UserRight.MANAGE_USERS | ||
33 | ], | 34 | ], |
34 | 35 | ||
35 | [UserRole.USER]: [] | 36 | [UserRole.USER]: [] |
diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts index 75d52023a..1222899e7 100644 --- a/shared/utils/users/user-notifications.ts +++ b/shared/utils/users/user-notifications.ts | |||
@@ -98,9 +98,11 @@ async function checkNotification ( | |||
98 | }) | 98 | }) |
99 | 99 | ||
100 | if (checkType === 'presence') { | 100 | if (checkType === 'presence') { |
101 | expect(socketNotification, 'The socket notification is absent. ' + inspect(base.socketNotifications)).to.not.be.undefined | 101 | const obj = inspect(base.socketNotifications, { depth: 5 }) |
102 | expect(socketNotification, 'The socket notification is absent. ' + obj).to.not.be.undefined | ||
102 | } else { | 103 | } else { |
103 | expect(socketNotification, 'The socket notification is present. ' + inspect(socketNotification)).to.be.undefined | 104 | const obj = inspect(socketNotification, { depth: 5 }) |
105 | expect(socketNotification, 'The socket notification is present. ' + obj).to.be.undefined | ||
104 | } | 106 | } |
105 | } | 107 | } |
106 | 108 | ||
@@ -131,10 +133,9 @@ function checkVideo (video: any, videoName?: string, videoUUID?: string) { | |||
131 | expect(video.id).to.be.a('number') | 133 | expect(video.id).to.be.a('number') |
132 | } | 134 | } |
133 | 135 | ||
134 | function checkActor (channel: any) { | 136 | function checkActor (actor: any) { |
135 | expect(channel.id).to.be.a('number') | 137 | expect(actor.displayName).to.be.a('string') |
136 | expect(channel.displayName).to.be.a('string') | 138 | expect(actor.displayName).to.not.be.empty |
137 | expect(channel.displayName).to.not.be.empty | ||
138 | } | 139 | } |
139 | 140 | ||
140 | function checkComment (comment: any, commentId: number, threadId: number) { | 141 | function checkComment (comment: any, commentId: number, threadId: number) { |
@@ -220,6 +221,103 @@ async function checkMyVideoImportIsFinished ( | |||
220 | await checkNotification(base, notificationChecker, emailFinder, type) | 221 | await checkNotification(base, notificationChecker, emailFinder, type) |
221 | } | 222 | } |
222 | 223 | ||
224 | async function checkUserRegistered (base: CheckerBaseParams, username: string, type: CheckerType) { | ||
225 | const notificationType = UserNotificationType.NEW_USER_REGISTRATION | ||
226 | |||
227 | function notificationChecker (notification: UserNotification, type: CheckerType) { | ||
228 | if (type === 'presence') { | ||
229 | expect(notification).to.not.be.undefined | ||
230 | expect(notification.type).to.equal(notificationType) | ||
231 | |||
232 | checkActor(notification.account) | ||
233 | expect(notification.account.name).to.equal(username) | ||
234 | } else { | ||
235 | expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username) | ||
236 | } | ||
237 | } | ||
238 | |||
239 | function emailFinder (email: object) { | ||
240 | const text: string = email[ 'text' ] | ||
241 | |||
242 | return text.includes(' registered ') && text.includes(username) | ||
243 | } | ||
244 | |||
245 | await checkNotification(base, notificationChecker, emailFinder, type) | ||
246 | } | ||
247 | |||
248 | async function checkNewActorFollow ( | ||
249 | base: CheckerBaseParams, | ||
250 | followType: 'channel' | 'account', | ||
251 | followerName: string, | ||
252 | followerDisplayName: string, | ||
253 | followingDisplayName: string, | ||
254 | type: CheckerType | ||
255 | ) { | ||
256 | const notificationType = UserNotificationType.NEW_FOLLOW | ||
257 | |||
258 | function notificationChecker (notification: UserNotification, type: CheckerType) { | ||
259 | if (type === 'presence') { | ||
260 | expect(notification).to.not.be.undefined | ||
261 | expect(notification.type).to.equal(notificationType) | ||
262 | |||
263 | checkActor(notification.actorFollow.follower) | ||
264 | expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName) | ||
265 | expect(notification.actorFollow.follower.name).to.equal(followerName) | ||
266 | |||
267 | checkActor(notification.actorFollow.following) | ||
268 | expect(notification.actorFollow.following.displayName).to.equal(followingDisplayName) | ||
269 | expect(notification.actorFollow.following.type).to.equal(followType) | ||
270 | } else { | ||
271 | expect(notification).to.satisfy(n => { | ||
272 | return n.type !== notificationType || | ||
273 | (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName) | ||
274 | }) | ||
275 | } | ||
276 | } | ||
277 | |||
278 | function emailFinder (email: object) { | ||
279 | const text: string = email[ 'text' ] | ||
280 | |||
281 | return text.includes('Your ' + followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) | ||
282 | } | ||
283 | |||
284 | await checkNotification(base, notificationChecker, emailFinder, type) | ||
285 | } | ||
286 | |||
287 | async function checkCommentMention ( | ||
288 | base: CheckerBaseParams, | ||
289 | uuid: string, | ||
290 | commentId: number, | ||
291 | threadId: number, | ||
292 | byAccountDisplayName: string, | ||
293 | type: CheckerType | ||
294 | ) { | ||
295 | const notificationType = UserNotificationType.COMMENT_MENTION | ||
296 | |||
297 | function notificationChecker (notification: UserNotification, type: CheckerType) { | ||
298 | if (type === 'presence') { | ||
299 | expect(notification).to.not.be.undefined | ||
300 | expect(notification.type).to.equal(notificationType) | ||
301 | |||
302 | checkComment(notification.comment, commentId, threadId) | ||
303 | checkActor(notification.comment.account) | ||
304 | expect(notification.comment.account.displayName).to.equal(byAccountDisplayName) | ||
305 | |||
306 | checkVideo(notification.comment.video, undefined, uuid) | ||
307 | } else { | ||
308 | expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId) | ||
309 | } | ||
310 | } | ||
311 | |||
312 | function emailFinder (email: object) { | ||
313 | const text: string = email[ 'text' ] | ||
314 | |||
315 | return text.includes(' mentioned ') && text.includes(uuid) && text.includes(byAccountDisplayName) | ||
316 | } | ||
317 | |||
318 | await checkNotification(base, notificationChecker, emailFinder, type) | ||
319 | } | ||
320 | |||
223 | let lastEmailCount = 0 | 321 | let lastEmailCount = 0 |
224 | async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) { | 322 | async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) { |
225 | const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO | 323 | const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO |
@@ -312,10 +410,13 @@ export { | |||
312 | CheckerType, | 410 | CheckerType, |
313 | checkNotification, | 411 | checkNotification, |
314 | checkMyVideoImportIsFinished, | 412 | checkMyVideoImportIsFinished, |
413 | checkUserRegistered, | ||
315 | checkVideoIsPublished, | 414 | checkVideoIsPublished, |
316 | checkNewVideoFromSubscription, | 415 | checkNewVideoFromSubscription, |
416 | checkNewActorFollow, | ||
317 | checkNewCommentOnMyVideo, | 417 | checkNewCommentOnMyVideo, |
318 | checkNewBlacklistOnMyVideo, | 418 | checkNewBlacklistOnMyVideo, |
419 | checkCommentMention, | ||
319 | updateMyNotificationSettings, | 420 | updateMyNotificationSettings, |
320 | checkNewVideoAbuseForModerators, | 421 | checkNewVideoAbuseForModerators, |
321 | getUserNotifications, | 422 | getUserNotifications, |