aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server/lib/activitypub/url.ts12
-rw-r--r--server/lib/emailer.ts440
-rw-r--r--server/lib/job-queue/handlers/video-import.ts4
-rw-r--r--server/lib/notifier.ts796
-rw-r--r--server/lib/notifier/index.ts1
-rw-r--r--server/lib/notifier/notifier.ts259
-rw-r--r--server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts67
-rw-r--r--server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts74
-rw-r--r--server/lib/notifier/shared/abuse/index.ts4
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts119
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts32
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts36
-rw-r--r--server/lib/notifier/shared/blacklist/index.ts3
-rw-r--r--server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts60
-rw-r--r--server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts58
-rw-r--r--server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts55
-rw-r--r--server/lib/notifier/shared/comment/comment-mention.ts111
-rw-r--r--server/lib/notifier/shared/comment/index.ts2
-rw-r--r--server/lib/notifier/shared/comment/new-comment-for-video-owner.ts76
-rw-r--r--server/lib/notifier/shared/common/abstract-notification.ts23
-rw-r--r--server/lib/notifier/shared/common/index.ts1
-rw-r--r--server/lib/notifier/shared/follow/auto-follow-for-instance.ts51
-rw-r--r--server/lib/notifier/shared/follow/follow-for-instance.ts68
-rw-r--r--server/lib/notifier/shared/follow/follow-for-user.ts82
-rw-r--r--server/lib/notifier/shared/follow/index.ts3
-rw-r--r--server/lib/notifier/shared/index.ts7
-rw-r--r--server/lib/notifier/shared/instance/index.ts3
-rw-r--r--server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts54
-rw-r--r--server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts58
-rw-r--r--server/lib/notifier/shared/instance/registration-for-moderators.ts49
-rw-r--r--server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts57
-rw-r--r--server/lib/notifier/shared/video-publication/import-finished-for-owner.ts97
-rw-r--r--server/lib/notifier/shared/video-publication/index.ts5
-rw-r--r--server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts61
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts11
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts10
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts9
37 files changed, 1627 insertions, 1231 deletions
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
index 7816b0be0..338398f2b 100644
--- a/server/lib/activitypub/url.ts
+++ b/server/lib/activitypub/url.ts
@@ -1,5 +1,6 @@
1import { WEBSERVER } from '../../initializers/constants' 1import { WEBSERVER } from '../../initializers/constants'
2import { 2import {
3 MAbuseFull,
3 MAbuseId, 4 MAbuseId,
4 MActor, 5 MActor,
5 MActorFollowActors, 6 MActorFollowActors,
@@ -112,6 +113,14 @@ function getUndoActivityPubUrl (originalUrl: string) {
112 return originalUrl + '/undo' 113 return originalUrl + '/undo'
113} 114}
114 115
116// ---------------------------------------------------------------------------
117
118function getAbuseTargetUrl (abuse: MAbuseFull) {
119 return abuse.VideoAbuse?.Video?.url ||
120 abuse.VideoCommentAbuse?.VideoComment?.url ||
121 abuse.FlaggedAccount.Actor.url
122}
123
115export { 124export {
116 getLocalVideoActivityPubUrl, 125 getLocalVideoActivityPubUrl,
117 getLocalVideoPlaylistActivityPubUrl, 126 getLocalVideoPlaylistActivityPubUrl,
@@ -135,5 +144,6 @@ export {
135 getLocalVideoSharesActivityPubUrl, 144 getLocalVideoSharesActivityPubUrl,
136 getLocalVideoCommentsActivityPubUrl, 145 getLocalVideoCommentsActivityPubUrl,
137 getLocalVideoLikesActivityPubUrl, 146 getLocalVideoLikesActivityPubUrl,
138 getLocalVideoDislikesActivityPubUrl 147 getLocalVideoDislikesActivityPubUrl,
148 getAbuseTargetUrl
139} 149}
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 458214f88..6bb61484b 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -1,20 +1,15 @@
1import { readFileSync } from 'fs-extra' 1import { readFileSync } from 'fs-extra'
2import { merge } from 'lodash' 2import { isArray, merge } from 'lodash'
3import { createTransport, Transporter } from 'nodemailer' 3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path' 4import { join } from 'path'
5import { VideoChannelModel } from '@server/models/video/video-channel' 5import { EmailPayload } from '@shared/models'
6import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
7import { MVideoImport, MVideoImportVideo } from '@server/types/models/video/video-import'
8import { AbuseState, EmailPayload, UserAbuse } from '@shared/models'
9import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model' 6import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
10import { isTestInstance, root } from '../helpers/core-utils' 7import { isTestInstance, root } from '../helpers/core-utils'
11import { bunyanLogger, logger } from '../helpers/logger' 8import { bunyanLogger, logger } from '../helpers/logger'
12import { CONFIG, isEmailEnabled } from '../initializers/config' 9import { CONFIG, isEmailEnabled } from '../initializers/config'
13import { WEBSERVER } from '../initializers/constants' 10import { WEBSERVER } from '../initializers/constants'
14import { MAbuseFull, MAbuseMessage, MAccountDefault, MActorFollowActors, MActorFollowFull, MPlugin, MUser } from '../types/models' 11import { MUser } from '../types/models'
15import { MCommentOwnerVideo, MVideo, MVideoAccountLight } from '../types/models/video'
16import { JobQueue } from './job-queue' 12import { JobQueue } from './job-queue'
17import { toSafeHtml } from '../helpers/markdown'
18 13
19const Email = require('email-templates') 14const Email = require('email-templates')
20 15
@@ -59,429 +54,6 @@ class Emailer {
59 } 54 }
60 } 55 }
61 56
62 addNewVideoFromSubscriberNotification (to: string[], video: MVideoAccountLight) {
63 const channelName = video.VideoChannel.getDisplayName()
64 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
65
66 const emailPayload: EmailPayload = {
67 to,
68 subject: channelName + ' just published a new video',
69 text: `Your subscription ${channelName} just published a new video: "${video.name}".`,
70 locals: {
71 title: 'New content ',
72 action: {
73 text: 'View video',
74 url: videoUrl
75 }
76 }
77 }
78
79 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
80 }
81
82 addNewFollowNotification (to: string[], actorFollow: MActorFollowFull, followType: 'account' | 'channel') {
83 const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
84
85 const emailPayload: EmailPayload = {
86 template: 'follower-on-channel',
87 to,
88 subject: `New follower on your channel ${followingName}`,
89 locals: {
90 followerName: actorFollow.ActorFollower.Account.getDisplayName(),
91 followerUrl: actorFollow.ActorFollower.url,
92 followingName,
93 followingUrl: actorFollow.ActorFollowing.url,
94 followType
95 }
96 }
97
98 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
99 }
100
101 addNewInstanceFollowerNotification (to: string[], actorFollow: MActorFollowActors) {
102 const awaitingApproval = actorFollow.state === 'pending' ? ' awaiting manual approval.' : ''
103
104 const emailPayload: EmailPayload = {
105 to,
106 subject: 'New instance follower',
107 text: `Your instance has a new follower: ${actorFollow.ActorFollower.url}${awaitingApproval}.`,
108 locals: {
109 title: 'New instance follower',
110 action: {
111 text: 'Review followers',
112 url: WEBSERVER.URL + '/admin/follows/followers-list'
113 }
114 }
115 }
116
117 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
118 }
119
120 addAutoInstanceFollowingNotification (to: string[], actorFollow: MActorFollowActors) {
121 const instanceUrl = actorFollow.ActorFollowing.url
122 const emailPayload: EmailPayload = {
123 to,
124 subject: 'Auto instance following',
125 text: `Your instance automatically followed a new instance: <a href="${instanceUrl}">${instanceUrl}</a>.`
126 }
127
128 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
129 }
130
131 myVideoPublishedNotification (to: string[], video: MVideo) {
132 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
133
134 const emailPayload: EmailPayload = {
135 to,
136 subject: `Your video ${video.name} has been published`,
137 text: `Your video "${video.name}" has been published.`,
138 locals: {
139 title: 'You video is live',
140 action: {
141 text: 'View video',
142 url: videoUrl
143 }
144 }
145 }
146
147 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
148 }
149
150 myVideoImportSuccessNotification (to: string[], videoImport: MVideoImportVideo) {
151 const videoUrl = WEBSERVER.URL + videoImport.Video.getWatchStaticPath()
152
153 const emailPayload: EmailPayload = {
154 to,
155 subject: `Your video import ${videoImport.getTargetIdentifier()} is complete`,
156 text: `Your video "${videoImport.getTargetIdentifier()}" just finished importing.`,
157 locals: {
158 title: 'Import complete',
159 action: {
160 text: 'View video',
161 url: videoUrl
162 }
163 }
164 }
165
166 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
167 }
168
169 myVideoImportErrorNotification (to: string[], videoImport: MVideoImport) {
170 const importUrl = WEBSERVER.URL + '/my-library/video-imports'
171
172 const text =
173 `Your video import "${videoImport.getTargetIdentifier()}" encountered an error.` +
174 '\n\n' +
175 `See your videos import dashboard for more information: <a href="${importUrl}">${importUrl}</a>.`
176
177 const emailPayload: EmailPayload = {
178 to,
179 subject: `Your video import "${videoImport.getTargetIdentifier()}" encountered an error`,
180 text,
181 locals: {
182 title: 'Import failed',
183 action: {
184 text: 'Review imports',
185 url: importUrl
186 }
187 }
188 }
189
190 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
191 }
192
193 addNewCommentOnMyVideoNotification (to: string[], comment: MCommentOwnerVideo) {
194 const video = comment.Video
195 const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
196 const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
197 const commentHtml = toSafeHtml(comment.text)
198
199 const emailPayload: EmailPayload = {
200 template: 'video-comment-new',
201 to,
202 subject: 'New comment on your video ' + video.name,
203 locals: {
204 accountName: comment.Account.getDisplayName(),
205 accountUrl: comment.Account.Actor.url,
206 comment,
207 commentHtml,
208 video,
209 videoUrl,
210 action: {
211 text: 'View comment',
212 url: commentUrl
213 }
214 }
215 }
216
217 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
218 }
219
220 addNewCommentMentionNotification (to: string[], comment: MCommentOwnerVideo) {
221 const accountName = comment.Account.getDisplayName()
222 const video = comment.Video
223 const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
224 const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
225 const commentHtml = toSafeHtml(comment.text)
226
227 const emailPayload: EmailPayload = {
228 template: 'video-comment-mention',
229 to,
230 subject: 'Mention on video ' + video.name,
231 locals: {
232 comment,
233 commentHtml,
234 video,
235 videoUrl,
236 accountName,
237 action: {
238 text: 'View comment',
239 url: commentUrl
240 }
241 }
242 }
243
244 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
245 }
246
247 addAbuseModeratorsNotification (to: string[], parameters: {
248 abuse: UserAbuse
249 abuseInstance: MAbuseFull
250 reporter: string
251 }) {
252 const { abuse, abuseInstance, reporter } = parameters
253
254 const action = {
255 text: 'View report #' + abuse.id,
256 url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
257 }
258
259 let emailPayload: EmailPayload
260
261 if (abuseInstance.VideoAbuse) {
262 const video = abuseInstance.VideoAbuse.Video
263 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
264
265 emailPayload = {
266 template: 'video-abuse-new',
267 to,
268 subject: `New video abuse report from ${reporter}`,
269 locals: {
270 videoUrl,
271 isLocal: video.remote === false,
272 videoCreatedAt: new Date(video.createdAt).toLocaleString(),
273 videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
274 videoName: video.name,
275 reason: abuse.reason,
276 videoChannel: abuse.video.channel,
277 reporter,
278 action
279 }
280 }
281 } else if (abuseInstance.VideoCommentAbuse) {
282 const comment = abuseInstance.VideoCommentAbuse.VideoComment
283 const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
284
285 emailPayload = {
286 template: 'video-comment-abuse-new',
287 to,
288 subject: `New comment abuse report from ${reporter}`,
289 locals: {
290 commentUrl,
291 videoName: comment.Video.name,
292 isLocal: comment.isOwned(),
293 commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
294 reason: abuse.reason,
295 flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(),
296 reporter,
297 action
298 }
299 }
300 } else {
301 const account = abuseInstance.FlaggedAccount
302 const accountUrl = account.getClientUrl()
303
304 emailPayload = {
305 template: 'account-abuse-new',
306 to,
307 subject: `New account abuse report from ${reporter}`,
308 locals: {
309 accountUrl,
310 accountDisplayName: account.getDisplayName(),
311 isLocal: account.isOwned(),
312 reason: abuse.reason,
313 reporter,
314 action
315 }
316 }
317 }
318
319 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
320 }
321
322 addAbuseStateChangeNotification (to: string[], abuse: MAbuseFull) {
323 const text = abuse.state === AbuseState.ACCEPTED
324 ? 'Report #' + abuse.id + ' has been accepted'
325 : 'Report #' + abuse.id + ' has been rejected'
326
327 const abuseUrl = WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
328
329 const action = {
330 text,
331 url: abuseUrl
332 }
333
334 const emailPayload: EmailPayload = {
335 template: 'abuse-state-change',
336 to,
337 subject: text,
338 locals: {
339 action,
340 abuseId: abuse.id,
341 abuseUrl,
342 isAccepted: abuse.state === AbuseState.ACCEPTED
343 }
344 }
345
346 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
347 }
348
349 addAbuseNewMessageNotification (
350 to: string[],
351 options: {
352 target: 'moderator' | 'reporter'
353 abuse: MAbuseFull
354 message: MAbuseMessage
355 accountMessage: MAccountDefault
356 }) {
357 const { abuse, target, message, accountMessage } = options
358
359 const text = 'New message on report #' + abuse.id
360 const abuseUrl = target === 'moderator'
361 ? WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
362 : WEBSERVER.URL + '/my-account/abuses?search=%23' + abuse.id
363
364 const action = {
365 text,
366 url: abuseUrl
367 }
368
369 const emailPayload: EmailPayload = {
370 template: 'abuse-new-message',
371 to,
372 subject: text,
373 locals: {
374 abuseId: abuse.id,
375 abuseUrl: action.url,
376 messageAccountName: accountMessage.getDisplayName(),
377 messageText: message.message,
378 action
379 }
380 }
381
382 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
383 }
384
385 async addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) {
386 const videoAutoBlacklistUrl = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
387 const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
388 const channel = (await VideoChannelModel.loadAndPopulateAccount(videoBlacklist.Video.channelId)).toFormattedSummaryJSON()
389
390 const emailPayload: EmailPayload = {
391 template: 'video-auto-blacklist-new',
392 to,
393 subject: 'A new video is pending moderation',
394 locals: {
395 channel,
396 videoUrl,
397 videoName: videoBlacklist.Video.name,
398 action: {
399 text: 'Review autoblacklist',
400 url: videoAutoBlacklistUrl
401 }
402 }
403 }
404
405 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
406 }
407
408 addNewUserRegistrationNotification (to: string[], user: MUser) {
409 const emailPayload: EmailPayload = {
410 template: 'user-registered',
411 to,
412 subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${user.username}`,
413 locals: {
414 user
415 }
416 }
417
418 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
419 }
420
421 addVideoBlacklistNotification (to: string[], videoBlacklist: MVideoBlacklistVideo) {
422 const videoName = videoBlacklist.Video.name
423 const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
424
425 const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
426 const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.INSTANCE.NAME} has been blacklisted${reasonString}.`
427
428 const emailPayload: EmailPayload = {
429 to,
430 subject: `Video ${videoName} blacklisted`,
431 text: blockedString,
432 locals: {
433 title: 'Your video was blacklisted'
434 }
435 }
436
437 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
438 }
439
440 addVideoUnblacklistNotification (to: string[], video: MVideo) {
441 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
442
443 const emailPayload: EmailPayload = {
444 to,
445 subject: `Video ${video.name} unblacklisted`,
446 text: `Your video "${video.name}" (${videoUrl}) on ${CONFIG.INSTANCE.NAME} has been unblacklisted.`,
447 locals: {
448 title: 'Your video was unblacklisted'
449 }
450 }
451
452 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
453 }
454
455 addNewPeerTubeVersionNotification (to: string[], latestVersion: string) {
456 const emailPayload: EmailPayload = {
457 to,
458 template: 'peertube-version-new',
459 subject: `A new PeerTube version is available: ${latestVersion}`,
460 locals: {
461 latestVersion
462 }
463 }
464
465 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
466 }
467
468 addNewPlugionVersionNotification (to: string[], plugin: MPlugin) {
469 const pluginUrl = WEBSERVER.URL + '/admin/plugins/list-installed?pluginType=' + plugin.type
470
471 const emailPayload: EmailPayload = {
472 to,
473 template: 'plugin-version-new',
474 subject: `A new plugin/theme version is available: ${plugin.name}@${plugin.latestVersion}`,
475 locals: {
476 pluginName: plugin.name,
477 latestVersion: plugin.latestVersion,
478 pluginUrl
479 }
480 }
481
482 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
483 }
484
485 addPasswordResetEmailJob (username: string, to: string, resetPasswordUrl: string) { 57 addPasswordResetEmailJob (username: string, to: string, resetPasswordUrl: string) {
486 const emailPayload: EmailPayload = { 58 const emailPayload: EmailPayload = {
487 template: 'password-reset', 59 template: 'password-reset',
@@ -578,7 +150,11 @@ class Emailer {
578 subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX 150 subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
579 }) 151 })
580 152
581 for (const to of options.to) { 153 const toEmails = isArray(options.to)
154 ? options.to
155 : [ options.to ]
156
157 for (const to of toEmails) {
582 const baseOptions: SendEmailDefaultOptions = { 158 const baseOptions: SendEmailDefaultOptions = {
583 template: 'common', 159 template: 'common',
584 message: { 160 message: {
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 6e425d09c..5fd2039b1 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -235,7 +235,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
235 }) 235 })
236 }) 236 })
237 237
238 Notifier.Instance.notifyOnFinishedVideoImport(videoImportUpdated, true) 238 Notifier.Instance.notifyOnFinishedVideoImport({ videoImport: videoImportUpdated, success: true })
239 239
240 if (video.isBlacklisted()) { 240 if (video.isBlacklisted()) {
241 const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video }) 241 const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video })
@@ -263,7 +263,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
263 } 263 }
264 await videoImport.save() 264 await videoImport.save()
265 265
266 Notifier.Instance.notifyOnFinishedVideoImport(videoImport, false) 266 Notifier.Instance.notifyOnFinishedVideoImport({ videoImport, success: false })
267 267
268 throw err 268 throw err
269 } 269 }
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
deleted file mode 100644
index 1f9ff16df..000000000
--- a/server/lib/notifier.ts
+++ /dev/null
@@ -1,796 +0,0 @@
1import { AccountModel } from '@server/models/account/account'
2import { getServerActor } from '@server/models/application/application'
3import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
4import {
5 MUser,
6 MUserAccount,
7 MUserDefault,
8 MUserNotifSettingAccount,
9 MUserWithNotificationSetting,
10 UserNotificationModelForApi
11} from '@server/types/models/user'
12import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
13import { MVideoImportVideo } from '@server/types/models/video/video-import'
14import { UserAbuse } from '@shared/models'
15import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
16import { VideoPrivacy, VideoState } from '../../shared/models/videos'
17import { logger } from '../helpers/logger'
18import { CONFIG } from '../initializers/config'
19import { AccountBlocklistModel } from '../models/account/account-blocklist'
20import { UserModel } from '../models/user/user'
21import { UserNotificationModel } from '../models/user/user-notification'
22import { MAbuseFull, MAbuseMessage, MAccountServer, MActorFollowFull, MApplication, MPlugin } from '../types/models'
23import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video'
24import { isBlockedByServerOrAccount } from './blocklist'
25import { Emailer } from './emailer'
26import { PeerTubeSocket } from './peertube-socket'
27
28class Notifier {
29
30 private static instance: Notifier
31
32 private constructor () {
33 }
34
35 notifyOnNewVideoIfNeeded (video: MVideoAccountLight): void {
36 // Only notify on public and published videos which are not blacklisted
37 if (video.privacy !== VideoPrivacy.PUBLIC || video.state !== VideoState.PUBLISHED || video.isBlacklisted()) return
38
39 this.notifySubscribersOfNewVideo(video)
40 .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
41 }
42
43 notifyOnVideoPublishedAfterTranscoding (video: MVideoFullLight): void {
44 // don't notify if didn't wait for transcoding or video is still blacklisted/waiting for scheduled update
45 if (!video.waitTranscoding || video.VideoBlacklist || video.ScheduleVideoUpdate) return
46
47 this.notifyOwnedVideoHasBeenPublished(video)
48 .catch(err => logger.error('Cannot notify owner that its video %s has been published after transcoding.', video.url, { err }))
49 }
50
51 notifyOnVideoPublishedAfterScheduledUpdate (video: MVideoFullLight): void {
52 // don't notify if video is still blacklisted or waiting for transcoding
53 if (video.VideoBlacklist || (video.waitTranscoding && video.state !== VideoState.PUBLISHED)) return
54
55 this.notifyOwnedVideoHasBeenPublished(video)
56 .catch(err => logger.error('Cannot notify owner that its video %s has been published after scheduled update.', video.url, { err }))
57 }
58
59 notifyOnVideoPublishedAfterRemovedFromAutoBlacklist (video: MVideoFullLight): void {
60 // don't notify if video is still waiting for transcoding or scheduled update
61 if (video.ScheduleVideoUpdate || (video.waitTranscoding && video.state !== VideoState.PUBLISHED)) return
62
63 this.notifyOwnedVideoHasBeenPublished(video)
64 .catch(err => {
65 logger.error('Cannot notify owner that its video %s has been published after removed from auto-blacklist.', video.url, { err })
66 })
67 }
68
69 notifyOnNewComment (comment: MCommentOwnerVideo): void {
70 this.notifyVideoOwnerOfNewComment(comment)
71 .catch(err => logger.error('Cannot notify video owner of new comment %s.', comment.url, { err }))
72
73 this.notifyOfCommentMention(comment)
74 .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
75 }
76
77 notifyOnNewAbuse (parameters: { abuse: UserAbuse, abuseInstance: MAbuseFull, reporter: string }): void {
78 this.notifyModeratorsOfNewAbuse(parameters)
79 .catch(err => logger.error('Cannot notify of new abuse %d.', parameters.abuseInstance.id, { err }))
80 }
81
82 notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
83 this.notifyModeratorsOfVideoAutoBlacklist(videoBlacklist)
84 .catch(err => logger.error('Cannot notify of auto-blacklist of video %s.', videoBlacklist.Video.url, { err }))
85 }
86
87 notifyOnVideoBlacklist (videoBlacklist: MVideoBlacklistVideo): void {
88 this.notifyVideoOwnerOfBlacklist(videoBlacklist)
89 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
90 }
91
92 notifyOnVideoUnblacklist (video: MVideoFullLight): void {
93 this.notifyVideoOwnerOfUnblacklist(video)
94 .catch(err => logger.error('Cannot notify video owner of unblacklist of %s.', video.url, { err }))
95 }
96
97 notifyOnFinishedVideoImport (videoImport: MVideoImportVideo, success: boolean): void {
98 this.notifyOwnerVideoImportIsFinished(videoImport, success)
99 .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
100 }
101
102 notifyOnNewUserRegistration (user: MUserDefault): void {
103 this.notifyModeratorsOfNewUserRegistration(user)
104 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
105 }
106
107 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
108 this.notifyUserOfNewActorFollow(actorFollow)
109 .catch(err => {
110 logger.error(
111 'Cannot notify owner of channel %s of a new follow by %s.',
112 actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
113 actorFollow.ActorFollower.Account.getDisplayName(),
114 { err }
115 )
116 })
117 }
118
119 notifyOfNewInstanceFollow (actorFollow: MActorFollowFull): void {
120 this.notifyAdminsOfNewInstanceFollow(actorFollow)
121 .catch(err => {
122 logger.error('Cannot notify administrators of new follower %s.', actorFollow.ActorFollower.url, { err })
123 })
124 }
125
126 notifyOfAutoInstanceFollowing (actorFollow: MActorFollowFull): void {
127 this.notifyAdminsOfAutoInstanceFollowing(actorFollow)
128 .catch(err => {
129 logger.error('Cannot notify administrators of auto instance following %s.', actorFollow.ActorFollowing.url, { err })
130 })
131 }
132
133 notifyOnAbuseStateChange (abuse: MAbuseFull): void {
134 this.notifyReporterOfAbuseStateChange(abuse)
135 .catch(err => {
136 logger.error('Cannot notify reporter of abuse %d state change.', abuse.id, { err })
137 })
138 }
139
140 notifyOnAbuseMessage (abuse: MAbuseFull, message: MAbuseMessage): void {
141 this.notifyOfNewAbuseMessage(abuse, message)
142 .catch(err => {
143 logger.error('Cannot notify on new abuse %d message.', abuse.id, { err })
144 })
145 }
146
147 notifyOfNewPeerTubeVersion (application: MApplication, latestVersion: string) {
148 this.notifyAdminsOfNewPeerTubeVersion(application, latestVersion)
149 .catch(err => {
150 logger.error('Cannot notify on new PeerTubeb version %s.', latestVersion, { err })
151 })
152 }
153
154 notifyOfNewPluginVersion (plugin: MPlugin) {
155 this.notifyAdminsOfNewPluginVersion(plugin)
156 .catch(err => {
157 logger.error('Cannot notify on new plugin version %s.', plugin.name, { err })
158 })
159 }
160
161 private async notifySubscribersOfNewVideo (video: MVideoAccountLight) {
162 // List all followers that are users
163 const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
164
165 logger.info('Notifying %d users of new video %s.', users.length, video.url)
166
167 function settingGetter (user: MUserWithNotificationSetting) {
168 return user.NotificationSetting.newVideoFromSubscription
169 }
170
171 async function notificationCreator (user: MUserWithNotificationSetting) {
172 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
173 type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
174 userId: user.id,
175 videoId: video.id
176 })
177 notification.Video = video
178
179 return notification
180 }
181
182 function emailSender (emails: string[]) {
183 return Emailer.Instance.addNewVideoFromSubscriberNotification(emails, video)
184 }
185
186 return this.notify({ users, settingGetter, notificationCreator, emailSender })
187 }
188
189 private async notifyVideoOwnerOfNewComment (comment: MCommentOwnerVideo) {
190 if (comment.Video.isOwned() === false) return
191
192 const user = await UserModel.loadByVideoId(comment.videoId)
193
194 // Not our user or user comments its own video
195 if (!user || comment.Account.userId === user.id) return
196
197 if (await this.isBlockedByServerOrUser(comment.Account, user)) return
198
199 logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
200
201 function settingGetter (user: MUserWithNotificationSetting) {
202 return user.NotificationSetting.newCommentOnMyVideo
203 }
204
205 async function notificationCreator (user: MUserWithNotificationSetting) {
206 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
207 type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
208 userId: user.id,
209 commentId: comment.id
210 })
211 notification.Comment = comment
212
213 return notification
214 }
215
216 function emailSender (emails: string[]) {
217 return Emailer.Instance.addNewCommentOnMyVideoNotification(emails, comment)
218 }
219
220 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
221 }
222
223 private async notifyOfCommentMention (comment: MCommentOwnerVideo) {
224 const extractedUsernames = comment.extractMentions()
225 logger.debug(
226 'Extracted %d username from comment %s.', extractedUsernames.length, comment.url,
227 { usernames: extractedUsernames, text: comment.text }
228 )
229
230 let users = await UserModel.listByUsernames(extractedUsernames)
231
232 if (comment.Video.isOwned()) {
233 const userException = await UserModel.loadByVideoId(comment.videoId)
234 users = users.filter(u => u.id !== userException.id)
235 }
236
237 // Don't notify if I mentioned myself
238 users = users.filter(u => u.Account.id !== comment.accountId)
239
240 if (users.length === 0) return
241
242 const serverAccountId = (await getServerActor()).Account.id
243 const sourceAccounts = users.map(u => u.Account.id).concat([ serverAccountId ])
244
245 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, comment.accountId)
246 const instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, comment.Account.Actor.serverId)
247
248 logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
249
250 function settingGetter (user: MUserNotifSettingAccount) {
251 const accountId = user.Account.id
252 if (
253 accountMutedHash[accountId] === true || instanceMutedHash[accountId] === true ||
254 accountMutedHash[serverAccountId] === true || instanceMutedHash[serverAccountId] === true
255 ) {
256 return UserNotificationSettingValue.NONE
257 }
258
259 return user.NotificationSetting.commentMention
260 }
261
262 async function notificationCreator (user: MUserNotifSettingAccount) {
263 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
264 type: UserNotificationType.COMMENT_MENTION,
265 userId: user.id,
266 commentId: comment.id
267 })
268 notification.Comment = comment
269
270 return notification
271 }
272
273 function emailSender (emails: string[]) {
274 return Emailer.Instance.addNewCommentMentionNotification(emails, comment)
275 }
276
277 return this.notify({ users, settingGetter, notificationCreator, emailSender })
278 }
279
280 private async notifyUserOfNewActorFollow (actorFollow: MActorFollowFull) {
281 if (actorFollow.ActorFollowing.isOwned() === false) return
282
283 // Account follows one of our account?
284 let followType: 'account' | 'channel' = 'channel'
285 let user = await UserModel.loadByChannelActorId(actorFollow.ActorFollowing.id)
286
287 // Account follows one of our channel?
288 if (!user) {
289 user = await UserModel.loadByAccountActorId(actorFollow.ActorFollowing.id)
290 followType = 'account'
291 }
292
293 if (!user) return
294
295 const followerAccount = actorFollow.ActorFollower.Account
296 const followerAccountWithActor = Object.assign(followerAccount, { Actor: actorFollow.ActorFollower })
297
298 if (await this.isBlockedByServerOrUser(followerAccountWithActor, user)) return
299
300 logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
301
302 function settingGetter (user: MUserWithNotificationSetting) {
303 return user.NotificationSetting.newFollow
304 }
305
306 async function notificationCreator (user: MUserWithNotificationSetting) {
307 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
308 type: UserNotificationType.NEW_FOLLOW,
309 userId: user.id,
310 actorFollowId: actorFollow.id
311 })
312 notification.ActorFollow = actorFollow
313
314 return notification
315 }
316
317 function emailSender (emails: string[]) {
318 return Emailer.Instance.addNewFollowNotification(emails, actorFollow, followType)
319 }
320
321 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
322 }
323
324 private async notifyAdminsOfNewInstanceFollow (actorFollow: MActorFollowFull) {
325 const admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
326
327 const follower = Object.assign(actorFollow.ActorFollower.Account, { Actor: actorFollow.ActorFollower })
328 if (await this.isBlockedByServerOrUser(follower)) return
329
330 logger.info('Notifying %d administrators of new instance follower: %s.', admins.length, actorFollow.ActorFollower.url)
331
332 function settingGetter (user: MUserWithNotificationSetting) {
333 return user.NotificationSetting.newInstanceFollower
334 }
335
336 async function notificationCreator (user: MUserWithNotificationSetting) {
337 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
338 type: UserNotificationType.NEW_INSTANCE_FOLLOWER,
339 userId: user.id,
340 actorFollowId: actorFollow.id
341 })
342 notification.ActorFollow = actorFollow
343
344 return notification
345 }
346
347 function emailSender (emails: string[]) {
348 return Emailer.Instance.addNewInstanceFollowerNotification(emails, actorFollow)
349 }
350
351 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
352 }
353
354 private async notifyAdminsOfAutoInstanceFollowing (actorFollow: MActorFollowFull) {
355 const admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
356
357 logger.info('Notifying %d administrators of auto instance following: %s.', admins.length, actorFollow.ActorFollowing.url)
358
359 function settingGetter (user: MUserWithNotificationSetting) {
360 return user.NotificationSetting.autoInstanceFollowing
361 }
362
363 async function notificationCreator (user: MUserWithNotificationSetting) {
364 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
365 type: UserNotificationType.AUTO_INSTANCE_FOLLOWING,
366 userId: user.id,
367 actorFollowId: actorFollow.id
368 })
369 notification.ActorFollow = actorFollow
370
371 return notification
372 }
373
374 function emailSender (emails: string[]) {
375 return Emailer.Instance.addAutoInstanceFollowingNotification(emails, actorFollow)
376 }
377
378 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
379 }
380
381 private async notifyModeratorsOfNewAbuse (parameters: {
382 abuse: UserAbuse
383 abuseInstance: MAbuseFull
384 reporter: string
385 }) {
386 const { abuse, abuseInstance } = parameters
387
388 const moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
389 if (moderators.length === 0) return
390
391 const url = this.getAbuseUrl(abuseInstance)
392
393 logger.info('Notifying %s user/moderators of new abuse %s.', moderators.length, url)
394
395 function settingGetter (user: MUserWithNotificationSetting) {
396 return user.NotificationSetting.abuseAsModerator
397 }
398
399 async function notificationCreator (user: MUserWithNotificationSetting) {
400 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
401 type: UserNotificationType.NEW_ABUSE_FOR_MODERATORS,
402 userId: user.id,
403 abuseId: abuse.id
404 })
405 notification.Abuse = abuseInstance
406
407 return notification
408 }
409
410 function emailSender (emails: string[]) {
411 return Emailer.Instance.addAbuseModeratorsNotification(emails, parameters)
412 }
413
414 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
415 }
416
417 private async notifyReporterOfAbuseStateChange (abuse: MAbuseFull) {
418 // Only notify our users
419 if (abuse.ReporterAccount.isOwned() !== true) return
420
421 const url = this.getAbuseUrl(abuse)
422
423 logger.info('Notifying reporter of abuse % of state change.', url)
424
425 const reporter = await UserModel.loadByAccountActorId(abuse.ReporterAccount.actorId)
426
427 function settingGetter (user: MUserWithNotificationSetting) {
428 return user.NotificationSetting.abuseStateChange
429 }
430
431 async function notificationCreator (user: MUserWithNotificationSetting) {
432 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
433 type: UserNotificationType.ABUSE_STATE_CHANGE,
434 userId: user.id,
435 abuseId: abuse.id
436 })
437 notification.Abuse = abuse
438
439 return notification
440 }
441
442 function emailSender (emails: string[]) {
443 return Emailer.Instance.addAbuseStateChangeNotification(emails, abuse)
444 }
445
446 return this.notify({ users: [ reporter ], settingGetter, notificationCreator, emailSender })
447 }
448
449 private async notifyOfNewAbuseMessage (abuse: MAbuseFull, message: MAbuseMessage) {
450 const url = this.getAbuseUrl(abuse)
451 logger.info('Notifying reporter and moderators of new abuse message on %s.', url)
452
453 const accountMessage = await AccountModel.load(message.accountId)
454
455 function settingGetter (user: MUserWithNotificationSetting) {
456 return user.NotificationSetting.abuseNewMessage
457 }
458
459 async function notificationCreator (user: MUserWithNotificationSetting) {
460 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
461 type: UserNotificationType.ABUSE_NEW_MESSAGE,
462 userId: user.id,
463 abuseId: abuse.id
464 })
465 notification.Abuse = abuse
466
467 return notification
468 }
469
470 function emailSenderReporter (emails: string[]) {
471 return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'reporter', abuse, message, accountMessage })
472 }
473
474 function emailSenderModerators (emails: string[]) {
475 return Emailer.Instance.addAbuseNewMessageNotification(emails, { target: 'moderator', abuse, message, accountMessage })
476 }
477
478 async function buildReporterOptions () {
479 // Only notify our users
480 if (abuse.ReporterAccount.isOwned() !== true) return undefined
481
482 const reporter = await UserModel.loadByAccountActorId(abuse.ReporterAccount.actorId)
483 // Don't notify my own message
484 if (reporter.Account.id === message.accountId) return undefined
485
486 return { users: [ reporter ], settingGetter, notificationCreator, emailSender: emailSenderReporter }
487 }
488
489 async function buildModeratorsOptions () {
490 let moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
491 // Don't notify my own message
492 moderators = moderators.filter(m => m.Account.id !== message.accountId)
493
494 if (moderators.length === 0) return undefined
495
496 return { users: moderators, settingGetter, notificationCreator, emailSender: emailSenderModerators }
497 }
498
499 const options = await Promise.all([
500 buildReporterOptions(),
501 buildModeratorsOptions()
502 ])
503
504 return Promise.all(
505 options
506 .filter(opt => !!opt)
507 .map(opt => this.notify(opt))
508 )
509 }
510
511 private async notifyModeratorsOfVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo) {
512 const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_BLACKLIST)
513 if (moderators.length === 0) return
514
515 logger.info('Notifying %s moderators of video auto-blacklist %s.', moderators.length, videoBlacklist.Video.url)
516
517 function settingGetter (user: MUserWithNotificationSetting) {
518 return user.NotificationSetting.videoAutoBlacklistAsModerator
519 }
520
521 async function notificationCreator (user: MUserWithNotificationSetting) {
522 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
523 type: UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS,
524 userId: user.id,
525 videoBlacklistId: videoBlacklist.id
526 })
527 notification.VideoBlacklist = videoBlacklist
528
529 return notification
530 }
531
532 function emailSender (emails: string[]) {
533 return Emailer.Instance.addVideoAutoBlacklistModeratorsNotification(emails, videoBlacklist)
534 }
535
536 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
537 }
538
539 private async notifyVideoOwnerOfBlacklist (videoBlacklist: MVideoBlacklistVideo) {
540 const user = await UserModel.loadByVideoId(videoBlacklist.videoId)
541 if (!user) return
542
543 logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
544
545 function settingGetter (user: MUserWithNotificationSetting) {
546 return user.NotificationSetting.blacklistOnMyVideo
547 }
548
549 async function notificationCreator (user: MUserWithNotificationSetting) {
550 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
551 type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
552 userId: user.id,
553 videoBlacklistId: videoBlacklist.id
554 })
555 notification.VideoBlacklist = videoBlacklist
556
557 return notification
558 }
559
560 function emailSender (emails: string[]) {
561 return Emailer.Instance.addVideoBlacklistNotification(emails, videoBlacklist)
562 }
563
564 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
565 }
566
567 private async notifyVideoOwnerOfUnblacklist (video: MVideoFullLight) {
568 const user = await UserModel.loadByVideoId(video.id)
569 if (!user) return
570
571 logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
572
573 function settingGetter (user: MUserWithNotificationSetting) {
574 return user.NotificationSetting.blacklistOnMyVideo
575 }
576
577 async function notificationCreator (user: MUserWithNotificationSetting) {
578 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
579 type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
580 userId: user.id,
581 videoId: video.id
582 })
583 notification.Video = video
584
585 return notification
586 }
587
588 function emailSender (emails: string[]) {
589 return Emailer.Instance.addVideoUnblacklistNotification(emails, video)
590 }
591
592 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
593 }
594
595 private async notifyOwnedVideoHasBeenPublished (video: MVideoFullLight) {
596 const user = await UserModel.loadByVideoId(video.id)
597 if (!user) return
598
599 logger.info('Notifying user %s of the publication of its video %s.', user.username, video.url)
600
601 function settingGetter (user: MUserWithNotificationSetting) {
602 return user.NotificationSetting.myVideoPublished
603 }
604
605 async function notificationCreator (user: MUserWithNotificationSetting) {
606 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
607 type: UserNotificationType.MY_VIDEO_PUBLISHED,
608 userId: user.id,
609 videoId: video.id
610 })
611 notification.Video = video
612
613 return notification
614 }
615
616 function emailSender (emails: string[]) {
617 return Emailer.Instance.myVideoPublishedNotification(emails, video)
618 }
619
620 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
621 }
622
623 private async notifyOwnerVideoImportIsFinished (videoImport: MVideoImportVideo, success: boolean) {
624 const user = await UserModel.loadByVideoImportId(videoImport.id)
625 if (!user) return
626
627 logger.info('Notifying user %s its video import %s is finished.', user.username, videoImport.getTargetIdentifier())
628
629 function settingGetter (user: MUserWithNotificationSetting) {
630 return user.NotificationSetting.myVideoImportFinished
631 }
632
633 async function notificationCreator (user: MUserWithNotificationSetting) {
634 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
635 type: success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR,
636 userId: user.id,
637 videoImportId: videoImport.id
638 })
639 notification.VideoImport = videoImport
640
641 return notification
642 }
643
644 function emailSender (emails: string[]) {
645 return success
646 ? Emailer.Instance.myVideoImportSuccessNotification(emails, videoImport)
647 : Emailer.Instance.myVideoImportErrorNotification(emails, videoImport)
648 }
649
650 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
651 }
652
653 private async notifyModeratorsOfNewUserRegistration (registeredUser: MUserDefault) {
654 const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
655 if (moderators.length === 0) return
656
657 logger.info(
658 'Notifying %s moderators of new user registration of %s.',
659 moderators.length, registeredUser.username
660 )
661
662 function settingGetter (user: MUserWithNotificationSetting) {
663 return user.NotificationSetting.newUserRegistration
664 }
665
666 async function notificationCreator (user: MUserWithNotificationSetting) {
667 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
668 type: UserNotificationType.NEW_USER_REGISTRATION,
669 userId: user.id,
670 accountId: registeredUser.Account.id
671 })
672 notification.Account = registeredUser.Account
673
674 return notification
675 }
676
677 function emailSender (emails: string[]) {
678 return Emailer.Instance.addNewUserRegistrationNotification(emails, registeredUser)
679 }
680
681 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
682 }
683
684 private async notifyAdminsOfNewPeerTubeVersion (application: MApplication, latestVersion: string) {
685 // Use the debug right to know who is an administrator
686 const admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
687 if (admins.length === 0) return
688
689 logger.info('Notifying %s admins of new PeerTube version %s.', admins.length, latestVersion)
690
691 function settingGetter (user: MUserWithNotificationSetting) {
692 return user.NotificationSetting.newPeerTubeVersion
693 }
694
695 async function notificationCreator (user: MUserWithNotificationSetting) {
696 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
697 type: UserNotificationType.NEW_PEERTUBE_VERSION,
698 userId: user.id,
699 applicationId: application.id
700 })
701 notification.Application = application
702
703 return notification
704 }
705
706 function emailSender (emails: string[]) {
707 return Emailer.Instance.addNewPeerTubeVersionNotification(emails, latestVersion)
708 }
709
710 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
711 }
712
713 private async notifyAdminsOfNewPluginVersion (plugin: MPlugin) {
714 // Use the debug right to know who is an administrator
715 const admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
716 if (admins.length === 0) return
717
718 logger.info('Notifying %s admins of new plugin version %s@%s.', admins.length, plugin.name, plugin.latestVersion)
719
720 function settingGetter (user: MUserWithNotificationSetting) {
721 return user.NotificationSetting.newPluginVersion
722 }
723
724 async function notificationCreator (user: MUserWithNotificationSetting) {
725 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
726 type: UserNotificationType.NEW_PLUGIN_VERSION,
727 userId: user.id,
728 pluginId: plugin.id
729 })
730 notification.Plugin = plugin
731
732 return notification
733 }
734
735 function emailSender (emails: string[]) {
736 return Emailer.Instance.addNewPlugionVersionNotification(emails, plugin)
737 }
738
739 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
740 }
741
742 private async notify<T extends MUserWithNotificationSetting> (options: {
743 users: T[]
744 notificationCreator: (user: T) => Promise<UserNotificationModelForApi>
745 emailSender: (emails: string[]) => void
746 settingGetter: (user: T) => UserNotificationSettingValue
747 }) {
748 const emails: string[] = []
749
750 for (const user of options.users) {
751 if (this.isWebNotificationEnabled(options.settingGetter(user))) {
752 const notification = await options.notificationCreator(user)
753
754 PeerTubeSocket.Instance.sendNotification(user.id, notification)
755 }
756
757 if (this.isEmailEnabled(user, options.settingGetter(user))) {
758 emails.push(user.email)
759 }
760 }
761
762 if (emails.length !== 0) {
763 options.emailSender(emails)
764 }
765 }
766
767 private isEmailEnabled (user: MUser, value: UserNotificationSettingValue) {
768 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified === false) return false
769
770 return value & UserNotificationSettingValue.EMAIL
771 }
772
773 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
774 return value & UserNotificationSettingValue.WEB
775 }
776
777 private isBlockedByServerOrUser (targetAccount: MAccountServer, user?: MUserAccount) {
778 return isBlockedByServerOrAccount(targetAccount, user?.Account)
779 }
780
781 private getAbuseUrl (abuse: MAbuseFull) {
782 return abuse.VideoAbuse?.Video?.url ||
783 abuse.VideoCommentAbuse?.VideoComment?.url ||
784 abuse.FlaggedAccount.Actor.url
785 }
786
787 static get Instance () {
788 return this.instance || (this.instance = new this())
789 }
790}
791
792// ---------------------------------------------------------------------------
793
794export {
795 Notifier
796}
diff --git a/server/lib/notifier/index.ts b/server/lib/notifier/index.ts
new file mode 100644
index 000000000..5bc2f5f50
--- /dev/null
+++ b/server/lib/notifier/index.ts
@@ -0,0 +1 @@
export * from './notifier'
diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts
new file mode 100644
index 000000000..8b68d2e69
--- /dev/null
+++ b/server/lib/notifier/notifier.ts
@@ -0,0 +1,259 @@
1import { MUser, MUserDefault } from '@server/types/models/user'
2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
3import { UserNotificationSettingValue } from '../../../shared/models/users'
4import { logger } from '../../helpers/logger'
5import { CONFIG } from '../../initializers/config'
6import { MAbuseFull, MAbuseMessage, MActorFollowFull, MApplication, MPlugin } from '../../types/models'
7import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../../types/models/video'
8import { JobQueue } from '../job-queue'
9import { PeerTubeSocket } from '../peertube-socket'
10import {
11 AbstractNotification,
12 AbuseStateChangeForReporter,
13 AutoFollowForInstance,
14 CommentMention,
15 FollowForInstance,
16 FollowForUser,
17 ImportFinishedForOwner,
18 ImportFinishedForOwnerPayload,
19 NewAbuseForModerators,
20 NewAbuseMessageForModerators,
21 NewAbuseMessageForReporter,
22 NewAbusePayload,
23 NewAutoBlacklistForModerators,
24 NewBlacklistForOwner,
25 NewCommentForVideoOwner,
26 NewPeerTubeVersionForAdmins,
27 NewPluginVersionForAdmins,
28 NewVideoForSubscribers,
29 OwnedPublicationAfterAutoUnblacklist,
30 OwnedPublicationAfterScheduleUpdate,
31 OwnedPublicationAfterTranscoding,
32 RegistrationForModerators,
33 UnblacklistForOwner
34} from './shared'
35
36class Notifier {
37
38 private readonly notificationModels = {
39 newVideo: [ NewVideoForSubscribers ],
40 publicationAfterTranscoding: [ OwnedPublicationAfterTranscoding ],
41 publicationAfterScheduleUpdate: [ OwnedPublicationAfterScheduleUpdate ],
42 publicationAfterAutoUnblacklist: [ OwnedPublicationAfterAutoUnblacklist ],
43 newComment: [ CommentMention, NewCommentForVideoOwner ],
44 newAbuse: [ NewAbuseForModerators ],
45 newBlacklist: [ NewBlacklistForOwner ],
46 unblacklist: [ UnblacklistForOwner ],
47 importFinished: [ ImportFinishedForOwner ],
48 userRegistration: [ RegistrationForModerators ],
49 userFollow: [ FollowForUser ],
50 instanceFollow: [ FollowForInstance ],
51 autoInstanceFollow: [ AutoFollowForInstance ],
52 newAutoBlacklist: [ NewAutoBlacklistForModerators ],
53 abuseStateChange: [ AbuseStateChangeForReporter ],
54 newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
55 newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
56 newPluginVersion: [ NewPluginVersionForAdmins ]
57 }
58
59 private static instance: Notifier
60
61 private constructor () {
62 }
63
64 notifyOnNewVideoIfNeeded (video: MVideoAccountLight): void {
65 const models = this.notificationModels.newVideo
66
67 this.sendNotifications(models, video)
68 .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
69 }
70
71 notifyOnVideoPublishedAfterTranscoding (video: MVideoFullLight): void {
72 const models = this.notificationModels.publicationAfterTranscoding
73
74 this.sendNotifications(models, video)
75 .catch(err => logger.error('Cannot notify owner that its video %s has been published after transcoding.', video.url, { err }))
76 }
77
78 notifyOnVideoPublishedAfterScheduledUpdate (video: MVideoFullLight): void {
79 const models = this.notificationModels.publicationAfterScheduleUpdate
80
81 this.sendNotifications(models, video)
82 .catch(err => logger.error('Cannot notify owner that its video %s has been published after scheduled update.', video.url, { err }))
83 }
84
85 notifyOnVideoPublishedAfterRemovedFromAutoBlacklist (video: MVideoFullLight): void {
86 const models = this.notificationModels.publicationAfterAutoUnblacklist
87
88 this.sendNotifications(models, video)
89 .catch(err => {
90 logger.error('Cannot notify owner that its video %s has been published after removed from auto-blacklist.', video.url, { err })
91 })
92 }
93
94 notifyOnNewComment (comment: MCommentOwnerVideo): void {
95 const models = this.notificationModels.newComment
96
97 this.sendNotifications(models, comment)
98 .catch(err => logger.error('Cannot notify of new comment.', comment.url, { err }))
99 }
100
101 notifyOnNewAbuse (payload: NewAbusePayload): void {
102 const models = this.notificationModels.newAbuse
103
104 this.sendNotifications(models, payload)
105 .catch(err => logger.error('Cannot notify of new abuse %d.', payload.abuseInstance.id, { err }))
106 }
107
108 notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
109 const models = this.notificationModels.newAutoBlacklist
110
111 this.sendNotifications(models, videoBlacklist)
112 .catch(err => logger.error('Cannot notify of auto-blacklist of video %s.', videoBlacklist.Video.url, { err }))
113 }
114
115 notifyOnVideoBlacklist (videoBlacklist: MVideoBlacklistVideo): void {
116 const models = this.notificationModels.newBlacklist
117
118 this.sendNotifications(models, videoBlacklist)
119 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
120 }
121
122 notifyOnVideoUnblacklist (video: MVideoFullLight): void {
123 const models = this.notificationModels.unblacklist
124
125 this.sendNotifications(models, video)
126 .catch(err => logger.error('Cannot notify video owner of unblacklist of %s.', video.url, { err }))
127 }
128
129 notifyOnFinishedVideoImport (payload: ImportFinishedForOwnerPayload): void {
130 const models = this.notificationModels.importFinished
131
132 this.sendNotifications(models, payload)
133 .catch(err => {
134 logger.error('Cannot notify owner that its video import %s is finished.', payload.videoImport.getTargetIdentifier(), { err })
135 })
136 }
137
138 notifyOnNewUserRegistration (user: MUserDefault): void {
139 const models = this.notificationModels.userRegistration
140
141 this.sendNotifications(models, user)
142 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
143 }
144
145 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
146 const models = this.notificationModels.userFollow
147
148 this.sendNotifications(models, actorFollow)
149 .catch(err => {
150 logger.error(
151 'Cannot notify owner of channel %s of a new follow by %s.',
152 actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
153 actorFollow.ActorFollower.Account.getDisplayName(),
154 { err }
155 )
156 })
157 }
158
159 notifyOfNewInstanceFollow (actorFollow: MActorFollowFull): void {
160 const models = this.notificationModels.instanceFollow
161
162 this.sendNotifications(models, actorFollow)
163 .catch(err => logger.error('Cannot notify administrators of new follower %s.', actorFollow.ActorFollower.url, { err }))
164 }
165
166 notifyOfAutoInstanceFollowing (actorFollow: MActorFollowFull): void {
167 const models = this.notificationModels.autoInstanceFollow
168
169 this.sendNotifications(models, actorFollow)
170 .catch(err => logger.error('Cannot notify administrators of auto instance following %s.', actorFollow.ActorFollowing.url, { err }))
171 }
172
173 notifyOnAbuseStateChange (abuse: MAbuseFull): void {
174 const models = this.notificationModels.abuseStateChange
175
176 this.sendNotifications(models, abuse)
177 .catch(err => logger.error('Cannot notify of abuse %d state change.', abuse.id, { err }))
178 }
179
180 notifyOnAbuseMessage (abuse: MAbuseFull, message: MAbuseMessage): void {
181 const models = this.notificationModels.newAbuseMessage
182
183 this.sendNotifications(models, { abuse, message })
184 .catch(err => logger.error('Cannot notify on new abuse %d message.', abuse.id, { err }))
185 }
186
187 notifyOfNewPeerTubeVersion (application: MApplication, latestVersion: string) {
188 const models = this.notificationModels.newPeertubeVersion
189
190 this.sendNotifications(models, { application, latestVersion })
191 .catch(err => logger.error('Cannot notify on new PeerTubeb version %s.', latestVersion, { err }))
192 }
193
194 notifyOfNewPluginVersion (plugin: MPlugin) {
195 const models = this.notificationModels.newPluginVersion
196
197 this.sendNotifications(models, plugin)
198 .catch(err => logger.error('Cannot notify on new plugin version %s.', plugin.name, { err }))
199 }
200
201 private async notify <T> (object: AbstractNotification<T>) {
202 await object.prepare()
203
204 const users = object.getTargetUsers()
205
206 if (users.length === 0) return
207 if (await object.isDisabled()) return
208
209 object.log()
210
211 const toEmails: string[] = []
212
213 for (const user of users) {
214 const setting = object.getSetting(user)
215
216 if (this.isWebNotificationEnabled(setting)) {
217 const notification = await object.createNotification(user)
218
219 PeerTubeSocket.Instance.sendNotification(user.id, notification)
220 }
221
222 if (this.isEmailEnabled(user, setting)) {
223 toEmails.push(user.email)
224 }
225 }
226
227 for (const to of toEmails) {
228 const payload = await object.createEmail(to)
229 JobQueue.Instance.createJob({ type: 'email', payload })
230 }
231 }
232
233 private isEmailEnabled (user: MUser, value: UserNotificationSettingValue) {
234 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified === false) return false
235
236 return value & UserNotificationSettingValue.EMAIL
237 }
238
239 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
240 return value & UserNotificationSettingValue.WEB
241 }
242
243 private async sendNotifications <T> (models: (new (payload: T) => AbstractNotification<T>)[], payload: T) {
244 for (const model of models) {
245 // eslint-disable-next-line new-cap
246 await this.notify(new model(payload))
247 }
248 }
249
250 static get Instance () {
251 return this.instance || (this.instance = new this())
252 }
253}
254
255// ---------------------------------------------------------------------------
256
257export {
258 Notifier
259}
diff --git a/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts b/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts
new file mode 100644
index 000000000..1425c38ec
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts
@@ -0,0 +1,67 @@
1import { WEBSERVER } from '@server/initializers/constants'
2import { AccountModel } from '@server/models/account/account'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MAbuseFull, MAbuseMessage, MAccountDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export type NewAbuseMessagePayload = {
9 abuse: MAbuseFull
10 message: MAbuseMessage
11}
12
13export abstract class AbstractNewAbuseMessage extends AbstractNotification <NewAbuseMessagePayload> {
14 protected messageAccount: MAccountDefault
15
16 async loadMessageAccount () {
17 this.messageAccount = await AccountModel.load(this.message.accountId)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.abuseNewMessage
22 }
23
24 async createNotification (user: MUserWithNotificationSetting) {
25 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
26 type: UserNotificationType.ABUSE_NEW_MESSAGE,
27 userId: user.id,
28 abuseId: this.abuse.id
29 })
30 notification.Abuse = this.abuse
31
32 return notification
33 }
34
35 protected createEmailFor (to: string, target: 'moderator' | 'reporter') {
36 const text = 'New message on report #' + this.abuse.id
37 const abuseUrl = target === 'moderator'
38 ? WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + this.abuse.id
39 : WEBSERVER.URL + '/my-account/abuses?search=%23' + this.abuse.id
40
41 const action = {
42 text,
43 url: abuseUrl
44 }
45
46 return {
47 template: 'abuse-new-message',
48 to,
49 subject: text,
50 locals: {
51 abuseId: this.abuse.id,
52 abuseUrl: action.url,
53 messageAccountName: this.messageAccount.getDisplayName(),
54 messageText: this.message.message,
55 action
56 }
57 }
58 }
59
60 protected get abuse () {
61 return this.payload.abuse
62 }
63
64 protected get message () {
65 return this.payload.message
66 }
67}
diff --git a/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts b/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts
new file mode 100644
index 000000000..968b5bca9
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts
@@ -0,0 +1,74 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MAbuseFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { AbuseState, UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class AbuseStateChangeForReporter extends AbstractNotification <MAbuseFull> {
11
12 private user: MUserDefault
13
14 async prepare () {
15 const reporter = this.abuse.ReporterAccount
16 if (reporter.isOwned() !== true) return
17
18 this.user = await UserModel.loadByAccountActorId(this.abuse.ReporterAccount.actorId)
19 }
20
21 log () {
22 logger.info('Notifying reporter of abuse % of state change.', getAbuseTargetUrl(this.abuse))
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.abuseStateChange
27 }
28
29 getTargetUsers () {
30 if (!this.user) return []
31
32 return [ this.user ]
33 }
34
35 async createNotification (user: MUserWithNotificationSetting) {
36 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
37 type: UserNotificationType.ABUSE_STATE_CHANGE,
38 userId: user.id,
39 abuseId: this.abuse.id
40 })
41 notification.Abuse = this.abuse
42
43 return notification
44 }
45
46 createEmail (to: string) {
47 const text = this.abuse.state === AbuseState.ACCEPTED
48 ? 'Report #' + this.abuse.id + ' has been accepted'
49 : 'Report #' + this.abuse.id + ' has been rejected'
50
51 const abuseUrl = WEBSERVER.URL + '/my-account/abuses?search=%23' + this.abuse.id
52
53 const action = {
54 text,
55 url: abuseUrl
56 }
57
58 return {
59 template: 'abuse-state-change',
60 to,
61 subject: text,
62 locals: {
63 action,
64 abuseId: this.abuse.id,
65 abuseUrl,
66 isAccepted: this.abuse.state === AbuseState.ACCEPTED
67 }
68 }
69 }
70
71 private get abuse () {
72 return this.payload
73 }
74}
diff --git a/server/lib/notifier/shared/abuse/index.ts b/server/lib/notifier/shared/abuse/index.ts
new file mode 100644
index 000000000..7b54c5591
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/index.ts
@@ -0,0 +1,4 @@
1export * from './abuse-state-change-for-reporter'
2export * from './new-abuse-for-moderators'
3export * from './new-abuse-message-for-reporter'
4export * from './new-abuse-message-for-moderators'
diff --git a/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts b/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts
new file mode 100644
index 000000000..c3c7c5515
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts
@@ -0,0 +1,119 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MAbuseFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { UserAbuse, UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export type NewAbusePayload = { abuse: UserAbuse, abuseInstance: MAbuseFull, reporter: string }
11
12export class NewAbuseForModerators extends AbstractNotification <NewAbusePayload> {
13 private moderators: MUserDefault[]
14
15 async prepare () {
16 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
17 }
18
19 log () {
20 logger.info('Notifying %s user/moderators of new abuse %s.', this.moderators.length, getAbuseTargetUrl(this.payload.abuseInstance))
21 }
22
23 getSetting (user: MUserWithNotificationSetting) {
24 return user.NotificationSetting.abuseAsModerator
25 }
26
27 getTargetUsers () {
28 return this.moderators
29 }
30
31 async createNotification (user: MUserWithNotificationSetting) {
32 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
33 type: UserNotificationType.NEW_ABUSE_FOR_MODERATORS,
34 userId: user.id,
35 abuseId: this.payload.abuseInstance.id
36 })
37 notification.Abuse = this.payload.abuseInstance
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const abuseInstance = this.payload.abuseInstance
44
45 if (abuseInstance.VideoAbuse) return this.createVideoAbuseEmail(to)
46 if (abuseInstance.VideoCommentAbuse) return this.createCommentAbuseEmail(to)
47
48 return this.createAccountAbuseEmail(to)
49 }
50
51 private createVideoAbuseEmail (to: string) {
52 const video = this.payload.abuseInstance.VideoAbuse.Video
53 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
54
55 return {
56 template: 'video-abuse-new',
57 to,
58 subject: `New video abuse report from ${this.payload.reporter}`,
59 locals: {
60 videoUrl,
61 isLocal: video.remote === false,
62 videoCreatedAt: new Date(video.createdAt).toLocaleString(),
63 videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
64 videoName: video.name,
65 reason: this.payload.abuse.reason,
66 videoChannel: this.payload.abuse.video.channel,
67 reporter: this.payload.reporter,
68 action: this.buildEmailAction()
69 }
70 }
71 }
72
73 private createCommentAbuseEmail (to: string) {
74 const comment = this.payload.abuseInstance.VideoCommentAbuse.VideoComment
75 const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
76
77 return {
78 template: 'video-comment-abuse-new',
79 to,
80 subject: `New comment abuse report from ${this.payload.reporter}`,
81 locals: {
82 commentUrl,
83 videoName: comment.Video.name,
84 isLocal: comment.isOwned(),
85 commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
86 reason: this.payload.abuse.reason,
87 flaggedAccount: this.payload.abuseInstance.FlaggedAccount.getDisplayName(),
88 reporter: this.payload.reporter,
89 action: this.buildEmailAction()
90 }
91 }
92 }
93
94 private createAccountAbuseEmail (to: string) {
95 const account = this.payload.abuseInstance.FlaggedAccount
96 const accountUrl = account.getClientUrl()
97
98 return {
99 template: 'account-abuse-new',
100 to,
101 subject: `New account abuse report from ${this.payload.reporter}`,
102 locals: {
103 accountUrl,
104 accountDisplayName: account.getDisplayName(),
105 isLocal: account.isOwned(),
106 reason: this.payload.abuse.reason,
107 reporter: this.payload.reporter,
108 action: this.buildEmailAction()
109 }
110 }
111 }
112
113 private buildEmailAction () {
114 return {
115 text: 'View report #' + this.payload.abuseInstance.id,
116 url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + this.payload.abuseInstance.id
117 }
118 }
119}
diff --git a/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts b/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts
new file mode 100644
index 000000000..9d0629690
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts
@@ -0,0 +1,32 @@
1import { logger } from '@server/helpers/logger'
2import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { UserRight } from '@shared/models'
6import { AbstractNewAbuseMessage } from './abstract-new-abuse-message'
7
8export class NewAbuseMessageForModerators extends AbstractNewAbuseMessage {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
13
14 // Don't notify my own message
15 this.moderators = this.moderators.filter(m => m.Account.id !== this.message.accountId)
16 if (this.moderators.length === 0) return
17
18 await this.loadMessageAccount()
19 }
20
21 log () {
22 logger.info('Notifying moderators of new abuse message on %s.', getAbuseTargetUrl(this.abuse))
23 }
24
25 getTargetUsers () {
26 return this.moderators
27 }
28
29 createEmail (to: string) {
30 return this.createEmailFor(to, 'moderator')
31 }
32}
diff --git a/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts b/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts
new file mode 100644
index 000000000..c5bbb5447
--- /dev/null
+++ b/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts
@@ -0,0 +1,36 @@
1import { logger } from '@server/helpers/logger'
2import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { AbstractNewAbuseMessage } from './abstract-new-abuse-message'
6
7export class NewAbuseMessageForReporter extends AbstractNewAbuseMessage {
8 private reporter: MUserDefault
9
10 async prepare () {
11 // Only notify our users
12 if (this.abuse.ReporterAccount.isOwned() !== true) return
13
14 await this.loadMessageAccount()
15
16 const reporter = await UserModel.loadByAccountActorId(this.abuse.ReporterAccount.actorId)
17 // Don't notify my own message
18 if (reporter.Account.id === this.message.accountId) return
19
20 this.reporter = reporter
21 }
22
23 log () {
24 logger.info('Notifying reporter of new abuse message on %s.', getAbuseTargetUrl(this.abuse))
25 }
26
27 getTargetUsers () {
28 if (!this.reporter) return []
29
30 return [ this.reporter ]
31 }
32
33 createEmail (to: string) {
34 return this.createEmailFor(to, 'reporter')
35 }
36}
diff --git a/server/lib/notifier/shared/blacklist/index.ts b/server/lib/notifier/shared/blacklist/index.ts
new file mode 100644
index 000000000..2f98d88ae
--- /dev/null
+++ b/server/lib/notifier/shared/blacklist/index.ts
@@ -0,0 +1,3 @@
1export * from './new-auto-blacklist-for-moderators'
2export * from './new-blacklist-for-owner'
3export * from './unblacklist-for-owner'
diff --git a/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts b/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts
new file mode 100644
index 000000000..a92a49a0c
--- /dev/null
+++ b/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts
@@ -0,0 +1,60 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { VideoChannelModel } from '@server/models/video/video-channel'
6import { MUserDefault, MUserWithNotificationSetting, MVideoBlacklistLightVideo, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class NewAutoBlacklistForModerators extends AbstractNotification <MVideoBlacklistLightVideo> {
11 private moderators: MUserDefault[]
12
13 async prepare () {
14 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_BLACKLIST)
15 }
16
17 log () {
18 logger.info('Notifying %s moderators of video auto-blacklist %s.', this.moderators.length, this.payload.Video.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.videoAutoBlacklistAsModerator
23 }
24
25 getTargetUsers () {
26 return this.moderators
27 }
28
29 async createNotification (user: MUserWithNotificationSetting) {
30 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
31 type: UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS,
32 userId: user.id,
33 videoBlacklistId: this.payload.id
34 })
35 notification.VideoBlacklist = this.payload
36
37 return notification
38 }
39
40 async createEmail (to: string) {
41 const videoAutoBlacklistUrl = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
42 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
43 const channel = await VideoChannelModel.loadAndPopulateAccount(this.payload.Video.channelId)
44
45 return {
46 template: 'video-auto-blacklist-new',
47 to,
48 subject: 'A new video is pending moderation',
49 locals: {
50 channel: channel.toFormattedSummaryJSON(),
51 videoUrl,
52 videoName: this.payload.Video.name,
53 action: {
54 text: 'Review autoblacklist',
55 url: videoAutoBlacklistUrl
56 }
57 }
58 }
59 }
60}
diff --git a/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts b/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts
new file mode 100644
index 000000000..45bc30eb2
--- /dev/null
+++ b/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts
@@ -0,0 +1,58 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MUserDefault, MUserWithNotificationSetting, MVideoBlacklistVideo, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class NewBlacklistForOwner extends AbstractNotification <MVideoBlacklistVideo> {
11 private user: MUserDefault
12
13 async prepare () {
14 this.user = await UserModel.loadByVideoId(this.payload.videoId)
15 }
16
17 log () {
18 logger.info('Notifying user %s that its video %s has been blacklisted.', this.user.username, this.payload.Video.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.blacklistOnMyVideo
23 }
24
25 getTargetUsers () {
26 if (!this.user) return []
27
28 return [ this.user ]
29 }
30
31 async createNotification (user: MUserWithNotificationSetting) {
32 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
33 type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
34 userId: user.id,
35 videoBlacklistId: this.payload.id
36 })
37 notification.VideoBlacklist = this.payload
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const videoName = this.payload.Video.name
44 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
45
46 const reasonString = this.payload.reason ? ` for the following reason: ${this.payload.reason}` : ''
47 const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.INSTANCE.NAME} has been blacklisted${reasonString}.`
48
49 return {
50 to,
51 subject: `Video ${videoName} blacklisted`,
52 text: blockedString,
53 locals: {
54 title: 'Your video was blacklisted'
55 }
56 }
57 }
58}
diff --git a/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts b/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts
new file mode 100644
index 000000000..21f5a1c2d
--- /dev/null
+++ b/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts
@@ -0,0 +1,55 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class UnblacklistForOwner extends AbstractNotification <MVideoFullLight> {
11 private user: MUserDefault
12
13 async prepare () {
14 this.user = await UserModel.loadByVideoId(this.payload.id)
15 }
16
17 log () {
18 logger.info('Notifying user %s that its video %s has been unblacklisted.', this.user.username, this.payload.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.blacklistOnMyVideo
23 }
24
25 getTargetUsers () {
26 if (!this.user) return []
27
28 return [ this.user ]
29 }
30
31 async createNotification (user: MUserWithNotificationSetting) {
32 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
33 type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
34 userId: user.id,
35 videoId: this.payload.id
36 })
37 notification.Video = this.payload
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const video = this.payload
44 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
45
46 return {
47 to,
48 subject: `Video ${video.name} unblacklisted`,
49 text: `Your video "${video.name}" (${videoUrl}) on ${CONFIG.INSTANCE.NAME} has been unblacklisted.`,
50 locals: {
51 title: 'Your video was unblacklisted'
52 }
53 }
54 }
55}
diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts
new file mode 100644
index 000000000..4f84d8dea
--- /dev/null
+++ b/server/lib/notifier/shared/comment/comment-mention.ts
@@ -0,0 +1,111 @@
1import { logger } from '@server/helpers/logger'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { WEBSERVER } from '@server/initializers/constants'
4import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
5import { getServerActor } from '@server/models/application/application'
6import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
7import { UserModel } from '@server/models/user/user'
8import { UserNotificationModel } from '@server/models/user/user-notification'
9import {
10 MCommentOwnerVideo,
11 MUserDefault,
12 MUserNotifSettingAccount,
13 MUserWithNotificationSetting,
14 UserNotificationModelForApi
15} from '@server/types/models'
16import { UserNotificationSettingValue, UserNotificationType } from '@shared/models'
17import { AbstractNotification } from '../common'
18
19export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MUserNotifSettingAccount> {
20 private users: MUserDefault[]
21
22 private serverAccountId: number
23
24 private accountMutedHash: { [ id: number ]: boolean }
25 private instanceMutedHash: { [ id: number ]: boolean }
26
27 async prepare () {
28 const extractedUsernames = this.payload.extractMentions()
29 logger.debug(
30 'Extracted %d username from comment %s.', extractedUsernames.length, this.payload.url,
31 { usernames: extractedUsernames, text: this.payload.text }
32 )
33
34 this.users = await UserModel.listByUsernames(extractedUsernames)
35
36 if (this.payload.Video.isOwned()) {
37 const userException = await UserModel.loadByVideoId(this.payload.videoId)
38 this.users = this.users.filter(u => u.id !== userException.id)
39 }
40
41 // Don't notify if I mentioned myself
42 this.users = this.users.filter(u => u.Account.id !== this.payload.accountId)
43
44 if (this.users.length === 0) return
45
46 this.serverAccountId = (await getServerActor()).Account.id
47
48 const sourceAccounts = this.users.map(u => u.Account.id).concat([ this.serverAccountId ])
49
50 this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, this.payload.accountId)
51 this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, this.payload.Account.Actor.serverId)
52 }
53
54 log () {
55 logger.info('Notifying %d users of new comment %s.', this.users.length, this.payload.url)
56 }
57
58 getSetting (user: MUserNotifSettingAccount) {
59 const accountId = user.Account.id
60 if (
61 this.accountMutedHash[accountId] === true || this.instanceMutedHash[accountId] === true ||
62 this.accountMutedHash[this.serverAccountId] === true || this.instanceMutedHash[this.serverAccountId] === true
63 ) {
64 return UserNotificationSettingValue.NONE
65 }
66
67 return user.NotificationSetting.commentMention
68 }
69
70 getTargetUsers () {
71 return this.users
72 }
73
74 async createNotification (user: MUserWithNotificationSetting) {
75 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
76 type: UserNotificationType.COMMENT_MENTION,
77 userId: user.id,
78 commentId: this.payload.id
79 })
80 notification.Comment = this.payload
81
82 return notification
83 }
84
85 createEmail (to: string) {
86 const comment = this.payload
87
88 const accountName = comment.Account.getDisplayName()
89 const video = comment.Video
90 const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
91 const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
92 const commentHtml = toSafeHtml(comment.text)
93
94 return {
95 template: 'video-comment-mention',
96 to,
97 subject: 'Mention on video ' + video.name,
98 locals: {
99 comment,
100 commentHtml,
101 video,
102 videoUrl,
103 accountName,
104 action: {
105 text: 'View comment',
106 url: commentUrl
107 }
108 }
109 }
110 }
111}
diff --git a/server/lib/notifier/shared/comment/index.ts b/server/lib/notifier/shared/comment/index.ts
new file mode 100644
index 000000000..ae01a9646
--- /dev/null
+++ b/server/lib/notifier/shared/comment/index.ts
@@ -0,0 +1,2 @@
1export * from './comment-mention'
2export * from './new-comment-for-video-owner'
diff --git a/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
new file mode 100644
index 000000000..b76fc15bf
--- /dev/null
+++ b/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
@@ -0,0 +1,76 @@
1import { logger } from '@server/helpers/logger'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { WEBSERVER } from '@server/initializers/constants'
4import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
5import { UserModel } from '@server/models/user/user'
6import { UserNotificationModel } from '@server/models/user/user-notification'
7import { MCommentOwnerVideo, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
8import { UserNotificationType } from '@shared/models'
9import { AbstractNotification } from '../common/abstract-notification'
10
11export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwnerVideo> {
12 private user: MUserDefault
13
14 async prepare () {
15 this.user = await UserModel.loadByVideoId(this.payload.videoId)
16 }
17
18 log () {
19 logger.info('Notifying owner of a video %s of new comment %s.', this.user.username, this.payload.url)
20 }
21
22 isDisabled () {
23 if (this.payload.Video.isOwned() === false) return true
24
25 // Not our user or user comments its own video
26 if (!this.user || this.payload.Account.userId === this.user.id) return true
27
28 return isBlockedByServerOrAccount(this.payload.Account, this.user.Account)
29 }
30
31 getSetting (user: MUserWithNotificationSetting) {
32 return user.NotificationSetting.newCommentOnMyVideo
33 }
34
35 getTargetUsers () {
36 if (!this.user) return []
37
38 return [ this.user ]
39 }
40
41 async createNotification (user: MUserWithNotificationSetting) {
42 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
43 type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
44 userId: user.id,
45 commentId: this.payload.id
46 })
47 notification.Comment = this.payload
48
49 return notification
50 }
51
52 createEmail (to: string) {
53 const video = this.payload.Video
54 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
55 const commentUrl = WEBSERVER.URL + this.payload.getCommentStaticPath()
56 const commentHtml = toSafeHtml(this.payload.text)
57
58 return {
59 template: 'video-comment-new',
60 to,
61 subject: 'New comment on your video ' + video.name,
62 locals: {
63 accountName: this.payload.Account.getDisplayName(),
64 accountUrl: this.payload.Account.Actor.url,
65 comment: this.payload,
66 commentHtml,
67 video,
68 videoUrl,
69 action: {
70 text: 'View comment',
71 url: commentUrl
72 }
73 }
74 }
75 }
76}
diff --git a/server/lib/notifier/shared/common/abstract-notification.ts b/server/lib/notifier/shared/common/abstract-notification.ts
new file mode 100644
index 000000000..53e2e02d5
--- /dev/null
+++ b/server/lib/notifier/shared/common/abstract-notification.ts
@@ -0,0 +1,23 @@
1import { MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
2import { EmailPayload, UserNotificationSettingValue } from '@shared/models'
3
4export abstract class AbstractNotification <T, U = MUserWithNotificationSetting> {
5
6 constructor (protected readonly payload: T) {
7
8 }
9
10 abstract prepare (): Promise<void>
11 abstract log (): void
12
13 abstract getSetting (user: U): UserNotificationSettingValue
14 abstract getTargetUsers (): U[]
15
16 abstract createNotification (user: U): Promise<UserNotificationModelForApi>
17 abstract createEmail (to: string): EmailPayload | Promise<EmailPayload>
18
19 isDisabled (): boolean | Promise<boolean> {
20 return false
21 }
22
23}
diff --git a/server/lib/notifier/shared/common/index.ts b/server/lib/notifier/shared/common/index.ts
new file mode 100644
index 000000000..0b2570278
--- /dev/null
+++ b/server/lib/notifier/shared/common/index.ts
@@ -0,0 +1 @@
export * from './abstract-notification'
diff --git a/server/lib/notifier/shared/follow/auto-follow-for-instance.ts b/server/lib/notifier/shared/follow/auto-follow-for-instance.ts
new file mode 100644
index 000000000..16cc62984
--- /dev/null
+++ b/server/lib/notifier/shared/follow/auto-follow-for-instance.ts
@@ -0,0 +1,51 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class AutoFollowForInstance extends AbstractNotification <MActorFollowFull> {
9 private admins: MUserDefault[]
10
11 async prepare () {
12 this.admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
13 }
14
15 log () {
16 logger.info('Notifying %d administrators of auto instance following: %s.', this.admins.length, this.actorFollow.ActorFollowing.url)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.autoInstanceFollowing
21 }
22
23 getTargetUsers () {
24 return this.admins
25 }
26
27 async createNotification (user: MUserWithNotificationSetting) {
28 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
29 type: UserNotificationType.AUTO_INSTANCE_FOLLOWING,
30 userId: user.id,
31 actorFollowId: this.actorFollow.id
32 })
33 notification.ActorFollow = this.actorFollow
34
35 return notification
36 }
37
38 async createEmail (to: string) {
39 const instanceUrl = this.actorFollow.ActorFollowing.url
40
41 return {
42 to,
43 subject: 'Auto instance following',
44 text: `Your instance automatically followed a new instance: <a href="${instanceUrl}">${instanceUrl}</a>.`
45 }
46 }
47
48 private get actorFollow () {
49 return this.payload
50 }
51}
diff --git a/server/lib/notifier/shared/follow/follow-for-instance.ts b/server/lib/notifier/shared/follow/follow-for-instance.ts
new file mode 100644
index 000000000..9ab269cf1
--- /dev/null
+++ b/server/lib/notifier/shared/follow/follow-for-instance.ts
@@ -0,0 +1,68 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class FollowForInstance extends AbstractNotification <MActorFollowFull> {
11 private admins: MUserDefault[]
12
13 async prepare () {
14 this.admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
15 }
16
17 isDisabled () {
18 const follower = Object.assign(this.actorFollow.ActorFollower.Account, { Actor: this.actorFollow.ActorFollower })
19
20 return isBlockedByServerOrAccount(follower)
21 }
22
23 log () {
24 logger.info('Notifying %d administrators of new instance follower: %s.', this.admins.length, this.actorFollow.ActorFollower.url)
25 }
26
27 getSetting (user: MUserWithNotificationSetting) {
28 return user.NotificationSetting.newInstanceFollower
29 }
30
31 getTargetUsers () {
32 return this.admins
33 }
34
35 async createNotification (user: MUserWithNotificationSetting) {
36 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
37 type: UserNotificationType.NEW_INSTANCE_FOLLOWER,
38 userId: user.id,
39 actorFollowId: this.actorFollow.id
40 })
41 notification.ActorFollow = this.actorFollow
42
43 return notification
44 }
45
46 async createEmail (to: string) {
47 const awaitingApproval = this.actorFollow.state === 'pending'
48 ? ' awaiting manual approval.'
49 : ''
50
51 return {
52 to,
53 subject: 'New instance follower',
54 text: `Your instance has a new follower: ${this.actorFollow.ActorFollower.url}${awaitingApproval}.`,
55 locals: {
56 title: 'New instance follower',
57 action: {
58 text: 'Review followers',
59 url: WEBSERVER.URL + '/admin/follows/followers-list'
60 }
61 }
62 }
63 }
64
65 private get actorFollow () {
66 return this.payload
67 }
68}
diff --git a/server/lib/notifier/shared/follow/follow-for-user.ts b/server/lib/notifier/shared/follow/follow-for-user.ts
new file mode 100644
index 000000000..2d0f675a8
--- /dev/null
+++ b/server/lib/notifier/shared/follow/follow-for-user.ts
@@ -0,0 +1,82 @@
1import { logger } from '@server/helpers/logger'
2import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class FollowForUser extends AbstractNotification <MActorFollowFull> {
10 private followType: 'account' | 'channel'
11 private user: MUserDefault
12
13 async prepare () {
14 // Account follows one of our account?
15 this.followType = 'channel'
16 this.user = await UserModel.loadByChannelActorId(this.actorFollow.ActorFollowing.id)
17
18 // Account follows one of our channel?
19 if (!this.user) {
20 this.user = await UserModel.loadByAccountActorId(this.actorFollow.ActorFollowing.id)
21 this.followType = 'account'
22 }
23 }
24
25 async isDisabled () {
26 if (this.payload.ActorFollowing.isOwned() === false) return true
27
28 const followerAccount = this.actorFollow.ActorFollower.Account
29 const followerAccountWithActor = Object.assign(followerAccount, { Actor: this.actorFollow.ActorFollower })
30
31 return isBlockedByServerOrAccount(followerAccountWithActor, this.user.Account)
32 }
33
34 log () {
35 logger.info('Notifying user %s of new follower: %s.', this.user.username, this.actorFollow.ActorFollower.Account.getDisplayName())
36 }
37
38 getSetting (user: MUserWithNotificationSetting) {
39 return user.NotificationSetting.newFollow
40 }
41
42 getTargetUsers () {
43 if (!this.user) return []
44
45 return [ this.user ]
46 }
47
48 async createNotification (user: MUserWithNotificationSetting) {
49 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
50 type: UserNotificationType.NEW_FOLLOW,
51 userId: user.id,
52 actorFollowId: this.actorFollow.id
53 })
54 notification.ActorFollow = this.actorFollow
55
56 return notification
57 }
58
59 async createEmail (to: string) {
60 const following = this.actorFollow.ActorFollowing
61 const follower = this.actorFollow.ActorFollower
62
63 const followingName = (following.VideoChannel || following.Account).getDisplayName()
64
65 return {
66 template: 'follower-on-channel',
67 to,
68 subject: `New follower on your channel ${followingName}`,
69 locals: {
70 followerName: follower.Account.getDisplayName(),
71 followerUrl: follower.url,
72 followingName,
73 followingUrl: following.url,
74 followType: this.followType
75 }
76 }
77 }
78
79 private get actorFollow () {
80 return this.payload
81 }
82}
diff --git a/server/lib/notifier/shared/follow/index.ts b/server/lib/notifier/shared/follow/index.ts
new file mode 100644
index 000000000..27f5289d9
--- /dev/null
+++ b/server/lib/notifier/shared/follow/index.ts
@@ -0,0 +1,3 @@
1export * from './auto-follow-for-instance'
2export * from './follow-for-instance'
3export * from './follow-for-user'
diff --git a/server/lib/notifier/shared/index.ts b/server/lib/notifier/shared/index.ts
new file mode 100644
index 000000000..cc3ce8c7c
--- /dev/null
+++ b/server/lib/notifier/shared/index.ts
@@ -0,0 +1,7 @@
1export * from './abuse'
2export * from './blacklist'
3export * from './comment'
4export * from './common'
5export * from './follow'
6export * from './instance'
7export * from './video-publication'
diff --git a/server/lib/notifier/shared/instance/index.ts b/server/lib/notifier/shared/instance/index.ts
new file mode 100644
index 000000000..c3bb22aec
--- /dev/null
+++ b/server/lib/notifier/shared/instance/index.ts
@@ -0,0 +1,3 @@
1export * from './new-peertube-version-for-admins'
2export * from './new-plugin-version-for-admins'
3export * from './registration-for-moderators'
diff --git a/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts b/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts
new file mode 100644
index 000000000..ab5bfb1ac
--- /dev/null
+++ b/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts
@@ -0,0 +1,54 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MApplication, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export type NewPeerTubeVersionForAdminsPayload = {
9 application: MApplication
10 latestVersion: string
11}
12
13export class NewPeerTubeVersionForAdmins extends AbstractNotification <NewPeerTubeVersionForAdminsPayload> {
14 private admins: MUserDefault[]
15
16 async prepare () {
17 // Use the debug right to know who is an administrator
18 this.admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
19 }
20
21 log () {
22 logger.info('Notifying %s admins of new PeerTube version %s.', this.admins.length, this.payload.latestVersion)
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.newPeerTubeVersion
27 }
28
29 getTargetUsers () {
30 return this.admins
31 }
32
33 async createNotification (user: MUserWithNotificationSetting) {
34 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
35 type: UserNotificationType.NEW_PEERTUBE_VERSION,
36 userId: user.id,
37 applicationId: this.payload.application.id
38 })
39 notification.Application = this.payload.application
40
41 return notification
42 }
43
44 async createEmail (to: string) {
45 return {
46 to,
47 template: 'peertube-version-new',
48 subject: `A new PeerTube version is available: ${this.payload.latestVersion}`,
49 locals: {
50 latestVersion: this.payload.latestVersion
51 }
52 }
53 }
54}
diff --git a/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts b/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts
new file mode 100644
index 000000000..e5e456a70
--- /dev/null
+++ b/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts
@@ -0,0 +1,58 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MPlugin, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class NewPluginVersionForAdmins extends AbstractNotification <MPlugin> {
10 private admins: MUserDefault[]
11
12 async prepare () {
13 // Use the debug right to know who is an administrator
14 this.admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
15 }
16
17 log () {
18 logger.info('Notifying %s admins of new PeerTube version %s.', this.admins.length, this.payload.latestVersion)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.newPluginVersion
23 }
24
25 getTargetUsers () {
26 return this.admins
27 }
28
29 async createNotification (user: MUserWithNotificationSetting) {
30 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
31 type: UserNotificationType.NEW_PLUGIN_VERSION,
32 userId: user.id,
33 pluginId: this.plugin.id
34 })
35 notification.Plugin = this.plugin
36
37 return notification
38 }
39
40 async createEmail (to: string) {
41 const pluginUrl = WEBSERVER.URL + '/admin/plugins/list-installed?pluginType=' + this.plugin.type
42
43 return {
44 to,
45 template: 'plugin-version-new',
46 subject: `A new plugin/theme version is available: ${this.plugin.name}@${this.plugin.latestVersion}`,
47 locals: {
48 pluginName: this.plugin.name,
49 latestVersion: this.plugin.latestVersion,
50 pluginUrl
51 }
52 }
53 }
54
55 private get plugin () {
56 return this.payload
57 }
58}
diff --git a/server/lib/notifier/shared/instance/registration-for-moderators.ts b/server/lib/notifier/shared/instance/registration-for-moderators.ts
new file mode 100644
index 000000000..4deb5a2cc
--- /dev/null
+++ b/server/lib/notifier/shared/instance/registration-for-moderators.ts
@@ -0,0 +1,49 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class RegistrationForModerators extends AbstractNotification <MUserDefault> {
10 private moderators: MUserDefault[]
11
12 async prepare () {
13 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
14 }
15
16 log () {
17 logger.info('Notifying %s moderators of new user registration of %s.', this.moderators.length, this.payload.username)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.newUserRegistration
22 }
23
24 getTargetUsers () {
25 return this.moderators
26 }
27
28 async createNotification (user: MUserWithNotificationSetting) {
29 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
30 type: UserNotificationType.NEW_USER_REGISTRATION,
31 userId: user.id,
32 accountId: this.payload.Account.id
33 })
34 notification.Account = this.payload.Account
35
36 return notification
37 }
38
39 async createEmail (to: string) {
40 return {
41 template: 'user-registered',
42 to,
43 subject: `a new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
44 locals: {
45 user: this.payload
46 }
47 }
48 }
49}
diff --git a/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts b/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts
new file mode 100644
index 000000000..fd06e080d
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts
@@ -0,0 +1,57 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export abstract class AbstractOwnedVideoPublication extends AbstractNotification <MVideoFullLight> {
10 protected user: MUserDefault
11
12 async prepare () {
13 this.user = await UserModel.loadByVideoId(this.payload.id)
14 }
15
16 log () {
17 logger.info('Notifying user %s of the publication of its video %s.', this.user.username, this.payload.url)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.myVideoPublished
22 }
23
24 getTargetUsers () {
25 if (!this.user) return []
26
27 return [ this.user ]
28 }
29
30 async createNotification (user: MUserWithNotificationSetting) {
31 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
32 type: UserNotificationType.MY_VIDEO_PUBLISHED,
33 userId: user.id,
34 videoId: this.payload.id
35 })
36 notification.Video = this.payload
37
38 return notification
39 }
40
41 createEmail (to: string) {
42 const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
43
44 return {
45 to,
46 subject: `Your video ${this.payload.name} has been published`,
47 text: `Your video "${this.payload.name}" has been published.`,
48 locals: {
49 title: 'You video is live',
50 action: {
51 text: 'View video',
52 url: videoUrl
53 }
54 }
55 }
56 }
57}
diff --git a/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts b/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts
new file mode 100644
index 000000000..9f374b6f9
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts
@@ -0,0 +1,97 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, MVideoImportVideo, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export type ImportFinishedForOwnerPayload = {
10 videoImport: MVideoImportVideo
11 success: boolean
12}
13
14export class ImportFinishedForOwner extends AbstractNotification <ImportFinishedForOwnerPayload> {
15 private user: MUserDefault
16
17 async prepare () {
18 this.user = await UserModel.loadByVideoImportId(this.videoImport.id)
19 }
20
21 log () {
22 logger.info('Notifying user %s its video import %s is finished.', this.user.username, this.videoImport.getTargetIdentifier())
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.myVideoImportFinished
27 }
28
29 getTargetUsers () {
30 if (!this.user) return []
31
32 return [ this.user ]
33 }
34
35 async createNotification (user: MUserWithNotificationSetting) {
36 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
37 type: this.payload.success
38 ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS
39 : UserNotificationType.MY_VIDEO_IMPORT_ERROR,
40
41 userId: user.id,
42 videoImportId: this.videoImport.id
43 })
44 notification.VideoImport = this.videoImport
45
46 return notification
47 }
48
49 createEmail (to: string) {
50 if (this.payload.success) return this.createSuccessEmail(to)
51
52 return this.createFailEmail(to)
53 }
54
55 private createSuccessEmail (to: string) {
56 const videoUrl = WEBSERVER.URL + this.videoImport.Video.getWatchStaticPath()
57
58 return {
59 to,
60 subject: `Your video import ${this.videoImport.getTargetIdentifier()} is complete`,
61 text: `Your video "${this.videoImport.getTargetIdentifier()}" just finished importing.`,
62 locals: {
63 title: 'Import complete',
64 action: {
65 text: 'View video',
66 url: videoUrl
67 }
68 }
69 }
70 }
71
72 private createFailEmail (to: string) {
73 const importUrl = WEBSERVER.URL + '/my-library/video-imports'
74
75 const text =
76 `Your video import "${this.videoImport.getTargetIdentifier()}" encountered an error.` +
77 '\n\n' +
78 `See your videos import dashboard for more information: <a href="${importUrl}">${importUrl}</a>.`
79
80 return {
81 to,
82 subject: `Your video import "${this.videoImport.getTargetIdentifier()}" encountered an error`,
83 text,
84 locals: {
85 title: 'Import failed',
86 action: {
87 text: 'Review imports',
88 url: importUrl
89 }
90 }
91 }
92 }
93
94 private get videoImport () {
95 return this.payload.videoImport
96 }
97}
diff --git a/server/lib/notifier/shared/video-publication/index.ts b/server/lib/notifier/shared/video-publication/index.ts
new file mode 100644
index 000000000..940774504
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/index.ts
@@ -0,0 +1,5 @@
1export * from './new-video-for-subscribers'
2export * from './import-finished-for-owner'
3export * from './owned-publication-after-auto-unblacklist'
4export * from './owned-publication-after-schedule-update'
5export * from './owned-publication-after-transcoding'
diff --git a/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts b/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts
new file mode 100644
index 000000000..4253a0930
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts
@@ -0,0 +1,61 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserWithNotificationSetting, MVideoAccountLight, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, VideoPrivacy, VideoState } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class NewVideoForSubscribers extends AbstractNotification <MVideoAccountLight> {
10 private users: MUserWithNotificationSetting[]
11
12 async prepare () {
13 // List all followers that are users
14 this.users = await UserModel.listUserSubscribersOf(this.payload.VideoChannel.actorId)
15 }
16
17 log () {
18 logger.info('Notifying %d users of new video %s.', this.users.length, this.payload.url)
19 }
20
21 isDisabled () {
22 return this.payload.privacy !== VideoPrivacy.PUBLIC || this.payload.state !== VideoState.PUBLISHED || this.payload.isBlacklisted()
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.newVideoFromSubscription
27 }
28
29 getTargetUsers () {
30 return this.users
31 }
32
33 async createNotification (user: MUserWithNotificationSetting) {
34 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
35 type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
36 userId: user.id,
37 videoId: this.payload.id
38 })
39 notification.Video = this.payload
40
41 return notification
42 }
43
44 createEmail (to: string) {
45 const channelName = this.payload.VideoChannel.getDisplayName()
46 const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
47
48 return {
49 to,
50 subject: channelName + ' just published a new video',
51 text: `Your subscription ${channelName} just published a new video: "${this.payload.name}".`,
52 locals: {
53 title: 'New content ',
54 action: {
55 text: 'View video',
56 url: videoUrl
57 }
58 }
59 }
60 }
61}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts
new file mode 100644
index 000000000..27d89a5c7
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts
@@ -0,0 +1,11 @@
1
2import { VideoState } from '@shared/models'
3import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
4
5export class OwnedPublicationAfterAutoUnblacklist extends AbstractOwnedVideoPublication {
6
7 isDisabled () {
8 // Don't notify if video is still waiting for transcoding or scheduled update
9 return !!this.payload.ScheduleVideoUpdate || (this.payload.waitTranscoding && this.payload.state !== VideoState.PUBLISHED)
10 }
11}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts
new file mode 100644
index 000000000..2e253b358
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts
@@ -0,0 +1,10 @@
1import { VideoState } from '@shared/models'
2import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
3
4export class OwnedPublicationAfterScheduleUpdate extends AbstractOwnedVideoPublication {
5
6 isDisabled () {
7 // Don't notify if video is still blacklisted or waiting for transcoding
8 return !!this.payload.VideoBlacklist || (this.payload.waitTranscoding && this.payload.state !== VideoState.PUBLISHED)
9 }
10}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts
new file mode 100644
index 000000000..4fab1090f
--- /dev/null
+++ b/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts
@@ -0,0 +1,9 @@
1import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
2
3export class OwnedPublicationAfterTranscoding extends AbstractOwnedVideoPublication {
4
5 isDisabled () {
6 // Don't notify if didn't wait for transcoding or video is still blacklisted/waiting for scheduled update
7 return !this.payload.waitTranscoding || !!this.payload.VideoBlacklist || !!this.payload.ScheduleVideoUpdate
8 }
9}