aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-12-26 10:36:24 +0100
committerChocobozzz <chocobozzz@cpy.re>2019-01-09 11:15:15 +0100
commitcef534ed53e4518fe0acf581bfe880788d42fc36 (patch)
tree115b51ea5136849a2336d44915c7780649f25dc2 /server
parent1de1d05f4c61fe059fa5e24e79c92582f0e7e4b3 (diff)
downloadPeerTube-cef534ed53e4518fe0acf581bfe880788d42fc36.tar.gz
PeerTube-cef534ed53e4518fe0acf581bfe880788d42fc36.tar.zst
PeerTube-cef534ed53e4518fe0acf581bfe880788d42fc36.zip
Add user notification base code
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/users/index.ts2
-rw-r--r--server/controllers/api/users/my-notifications.ts84
-rw-r--r--server/controllers/api/videos/abuse.ts3
-rw-r--r--server/controllers/api/videos/blacklist.ts14
-rw-r--r--server/controllers/api/videos/comment.ts3
-rw-r--r--server/controllers/api/videos/index.ts10
-rw-r--r--server/controllers/feeds.ts2
-rw-r--r--server/controllers/tracker.ts4
-rw-r--r--server/helpers/custom-validators/misc.ts5
-rw-r--r--server/helpers/custom-validators/user-notifications.ts19
-rw-r--r--server/initializers/constants.ts4
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/lib/activitypub/process/process-announce.ts8
-rw-r--r--server/lib/activitypub/process/process-create.ts14
-rw-r--r--server/lib/activitypub/video-comments.ts4
-rw-r--r--server/lib/activitypub/videos.ts15
-rw-r--r--server/lib/client-html.ts4
-rw-r--r--server/lib/emailer.ts116
-rw-r--r--server/lib/job-queue/handlers/video-file.ts5
-rw-r--r--server/lib/job-queue/handlers/video-import.ts2
-rw-r--r--server/lib/notifier.ts235
-rw-r--r--server/lib/oauth-model.ts3
-rw-r--r--server/lib/peertube-socket.ts52
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts5
-rw-r--r--server/lib/user.ts16
-rw-r--r--server/middlewares/oauth.ts22
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/user-history.ts8
-rw-r--r--server/middlewares/validators/user-notifications.ts46
-rw-r--r--server/models/account/user-notification-setting.ts100
-rw-r--r--server/models/account/user-notification.ts256
-rw-r--r--server/models/account/user.ts101
-rw-r--r--server/models/activitypub/actor-follow.ts14
-rw-r--r--server/models/activitypub/actor.ts1
-rw-r--r--server/models/video/video-abuse.ts5
-rw-r--r--server/models/video/video-blacklist.ts10
-rw-r--r--server/models/video/video-comment.ts4
-rw-r--r--server/models/video/video.ts4
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/user-notifications.ts249
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/user-notifications.ts628
42 files changed, 1992 insertions, 98 deletions
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index bc24792a2..98be46ea2 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -39,6 +39,7 @@ import { meRouter } from './me'
39import { deleteUserToken } from '../../../lib/oauth-model' 39import { deleteUserToken } from '../../../lib/oauth-model'
40import { myBlocklistRouter } from './my-blocklist' 40import { myBlocklistRouter } from './my-blocklist'
41import { myVideosHistoryRouter } from './my-history' 41import { myVideosHistoryRouter } from './my-history'
42import { myNotificationsRouter } from './my-notifications'
42 43
43const auditLogger = auditLoggerFactory('users') 44const auditLogger = auditLoggerFactory('users')
44 45
@@ -55,6 +56,7 @@ const askSendEmailLimiter = new RateLimit({
55}) 56})
56 57
57const usersRouter = express.Router() 58const usersRouter = express.Router()
59usersRouter.use('/', myNotificationsRouter)
58usersRouter.use('/', myBlocklistRouter) 60usersRouter.use('/', myBlocklistRouter)
59usersRouter.use('/', myVideosHistoryRouter) 61usersRouter.use('/', myVideosHistoryRouter)
60usersRouter.use('/', meRouter) 62usersRouter.use('/', meRouter)
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
new file mode 100644
index 000000000..cef1d237c
--- /dev/null
+++ b/server/controllers/api/users/my-notifications.ts
@@ -0,0 +1,84 @@
1import * as express from 'express'
2import 'multer'
3import {
4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 paginationValidator,
8 setDefaultPagination,
9 setDefaultSort,
10 userNotificationsSortValidator
11} from '../../../middlewares'
12import { UserModel } from '../../../models/account/user'
13import { getFormattedObjects } from '../../../helpers/utils'
14import { UserNotificationModel } from '../../../models/account/user-notification'
15import { meRouter } from './me'
16import {
17 markAsReadUserNotificationsValidator,
18 updateNotificationSettingsValidator
19} from '../../../middlewares/validators/user-notifications'
20import { UserNotificationSetting } from '../../../../shared/models/users'
21import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
22
23const myNotificationsRouter = express.Router()
24
25meRouter.put('/me/notification-settings',
26 authenticate,
27 updateNotificationSettingsValidator,
28 asyncRetryTransactionMiddleware(updateNotificationSettings)
29)
30
31myNotificationsRouter.get('/me/notifications',
32 authenticate,
33 paginationValidator,
34 userNotificationsSortValidator,
35 setDefaultSort,
36 setDefaultPagination,
37 asyncMiddleware(listUserNotifications)
38)
39
40myNotificationsRouter.post('/me/notifications/read',
41 authenticate,
42 markAsReadUserNotificationsValidator,
43 asyncMiddleware(markAsReadUserNotifications)
44)
45
46export {
47 myNotificationsRouter
48}
49
50// ---------------------------------------------------------------------------
51
52async function updateNotificationSettings (req: express.Request, res: express.Response) {
53 const user: UserModel = res.locals.oauth.token.User
54 const body: UserNotificationSetting = req.body
55
56 const query = {
57 where: {
58 userId: user.id
59 }
60 }
61
62 await UserNotificationSettingModel.update({
63 newVideoFromSubscription: body.newVideoFromSubscription,
64 newCommentOnMyVideo: body.newCommentOnMyVideo
65 }, query)
66
67 return res.status(204).end()
68}
69
70async function listUserNotifications (req: express.Request, res: express.Response) {
71 const user: UserModel = res.locals.oauth.token.User
72
73 const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort)
74
75 return res.json(getFormattedObjects(resultList.data, resultList.total))
76}
77
78async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
79 const user: UserModel = res.locals.oauth.token.User
80
81 await UserNotificationModel.markAsRead(user.id, req.body.ids)
82
83 return res.status(204).end()
84}
diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts
index d0c81804b..fe0a95cd5 100644
--- a/server/controllers/api/videos/abuse.ts
+++ b/server/controllers/api/videos/abuse.ts
@@ -22,6 +22,7 @@ import { VideoModel } from '../../../models/video/video'
22import { VideoAbuseModel } from '../../../models/video/video-abuse' 22import { VideoAbuseModel } from '../../../models/video/video-abuse'
23import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger' 23import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
24import { UserModel } from '../../../models/account/user' 24import { UserModel } from '../../../models/account/user'
25import { Notifier } from '../../../lib/notifier'
25 26
26const auditLogger = auditLoggerFactory('abuse') 27const auditLogger = auditLoggerFactory('abuse')
27const abuseVideoRouter = express.Router() 28const abuseVideoRouter = express.Router()
@@ -117,6 +118,8 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
117 await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance) 118 await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance)
118 } 119 }
119 120
121 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
122
120 auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON())) 123 auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON()))
121 124
122 return videoAbuseInstance 125 return videoAbuseInstance
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
index 7f803c8e9..9ef08812b 100644
--- a/server/controllers/api/videos/blacklist.ts
+++ b/server/controllers/api/videos/blacklist.ts
@@ -16,6 +16,8 @@ import {
16} from '../../../middlewares' 16} from '../../../middlewares'
17import { VideoBlacklistModel } from '../../../models/video/video-blacklist' 17import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
18import { sequelizeTypescript } from '../../../initializers' 18import { sequelizeTypescript } from '../../../initializers'
19import { Notifier } from '../../../lib/notifier'
20import { VideoModel } from '../../../models/video/video'
19 21
20const blacklistRouter = express.Router() 22const blacklistRouter = express.Router()
21 23
@@ -67,13 +69,18 @@ async function addVideoToBlacklist (req: express.Request, res: express.Response)
67 reason: body.reason 69 reason: body.reason
68 } 70 }
69 71
70 await VideoBlacklistModel.create(toCreate) 72 const blacklist = await VideoBlacklistModel.create(toCreate)
73 blacklist.Video = videoInstance
74
75 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
76
77 logger.info('Video %s blacklisted.', res.locals.video.uuid)
78
71 return res.type('json').status(204).end() 79 return res.type('json').status(204).end()
72} 80}
73 81
74async function updateVideoBlacklistController (req: express.Request, res: express.Response) { 82async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
75 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel 83 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
76 logger.info(videoBlacklist)
77 84
78 if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason 85 if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
79 86
@@ -92,11 +99,14 @@ async function listBlacklist (req: express.Request, res: express.Response, next:
92 99
93async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) { 100async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) {
94 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel 101 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
102 const video: VideoModel = res.locals.video
95 103
96 await sequelizeTypescript.transaction(t => { 104 await sequelizeTypescript.transaction(t => {
97 return videoBlacklist.destroy({ transaction: t }) 105 return videoBlacklist.destroy({ transaction: t })
98 }) 106 })
99 107
108 Notifier.Instance.notifyOnVideoUnblacklist(video)
109
100 logger.info('Video %s removed from blacklist.', res.locals.video.uuid) 110 logger.info('Video %s removed from blacklist.', res.locals.video.uuid)
101 111
102 return res.type('json').status(204).end() 112 return res.type('json').status(204).end()
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 3875c8f79..70c1148ba 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
26import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' 26import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
27import { AccountModel } from '../../../models/account/account' 27import { AccountModel } from '../../../models/account/account'
28import { UserModel } from '../../../models/account/user' 28import { UserModel } from '../../../models/account/user'
29import { Notifier } from '../../../lib/notifier'
29 30
30const auditLogger = auditLoggerFactory('comments') 31const auditLogger = auditLoggerFactory('comments')
31const videoCommentRouter = express.Router() 32const videoCommentRouter = express.Router()
@@ -119,6 +120,7 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
119 }, t) 120 }, t)
120 }) 121 })
121 122
123 Notifier.Instance.notifyOnNewComment(comment)
122 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) 124 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
123 125
124 return res.json({ 126 return res.json({
@@ -140,6 +142,7 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
140 }, t) 142 }, t)
141 }) 143 })
142 144
145 Notifier.Instance.notifyOnNewComment(comment)
143 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON())) 146 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
144 147
145 return res.json({ comment: comment.toFormattedJSON() }).end() 148 return res.json({ comment: comment.toFormattedJSON() }).end()
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 00a1302d1..94ed08fed 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -7,7 +7,8 @@ import { logger } from '../../../helpers/logger'
7import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 7import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
8import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 8import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
9import { 9import {
10 CONFIG, MIMETYPES, 10 CONFIG,
11 MIMETYPES,
11 PREVIEWS_SIZE, 12 PREVIEWS_SIZE,
12 sequelizeTypescript, 13 sequelizeTypescript,
13 THUMBNAILS_SIZE, 14 THUMBNAILS_SIZE,
@@ -57,6 +58,7 @@ import { videoImportsRouter } from './import'
57import { resetSequelizeInstance } from '../../../helpers/database-utils' 58import { resetSequelizeInstance } from '../../../helpers/database-utils'
58import { move } from 'fs-extra' 59import { move } from 'fs-extra'
59import { watchingRouter } from './watching' 60import { watchingRouter } from './watching'
61import { Notifier } from '../../../lib/notifier'
60 62
61const auditLogger = auditLoggerFactory('videos') 63const auditLogger = auditLoggerFactory('videos')
62const videosRouter = express.Router() 64const videosRouter = express.Router()
@@ -262,6 +264,7 @@ async function addVideo (req: express.Request, res: express.Response) {
262 } 264 }
263 265
264 await federateVideoIfNeeded(video, true, t) 266 await federateVideoIfNeeded(video, true, t)
267 Notifier.Instance.notifyOnNewVideo(video)
265 268
266 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) 269 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
267 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) 270 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
@@ -293,6 +296,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
293 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON()) 296 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
294 const videoInfoToUpdate: VideoUpdate = req.body 297 const videoInfoToUpdate: VideoUpdate = req.body
295 const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE 298 const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
299 const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
296 300
297 // Process thumbnail or create it from the video 301 // Process thumbnail or create it from the video
298 if (req.files && req.files['thumbnailfile']) { 302 if (req.files && req.files['thumbnailfile']) {
@@ -363,6 +367,10 @@ async function updateVideo (req: express.Request, res: express.Response) {
363 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE 367 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
364 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t) 368 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
365 369
370 if (wasUnlistedVideo || wasPrivateVideo) {
371 Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
372 }
373
366 auditLogger.update( 374 auditLogger.update(
367 getAuditIdFromRes(res), 375 getAuditIdFromRes(res),
368 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()), 376 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index ccb9b6029..960085af1 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -56,7 +56,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
56 56
57 // Adding video items to the feed, one at a time 57 // Adding video items to the feed, one at a time
58 comments.forEach(comment => { 58 comments.forEach(comment => {
59 const link = CONFIG.WEBSERVER.URL + '/videos/watch/' + comment.Video.uuid + ';threadId=' + comment.getThreadId() 59 const link = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
60 60
61 feed.addItem({ 61 feed.addItem({
62 title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`, 62 title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`,
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 9bc7586d1..53f1653b5 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -59,7 +59,7 @@ const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
59trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' })) 59trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
60trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' })) 60trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
61 61
62function createWebsocketServer (app: express.Application) { 62function createWebsocketTrackerServer (app: express.Application) {
63 const server = http.createServer(app) 63 const server = http.createServer(app)
64 const wss = new WebSocketServer({ server: server, path: '/tracker/socket' }) 64 const wss = new WebSocketServer({ server: server, path: '/tracker/socket' })
65 wss.on('connection', function (ws, req) { 65 wss.on('connection', function (ws, req) {
@@ -76,7 +76,7 @@ function createWebsocketServer (app: express.Application) {
76 76
77export { 77export {
78 trackerRouter, 78 trackerRouter,
79 createWebsocketServer 79 createWebsocketTrackerServer
80} 80}
81 81
82// --------------------------------------------------------------------------- 82// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 6d10a65a8..a093e3e1b 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -9,6 +9,10 @@ function isArray (value: any) {
9 return Array.isArray(value) 9 return Array.isArray(value)
10} 10}
11 11
12function isIntArray (value: any) {
13 return Array.isArray(value) && value.every(v => validator.isInt('' + v))
14}
15
12function isDateValid (value: string) { 16function isDateValid (value: string) {
13 return exists(value) && validator.isISO8601(value) 17 return exists(value) && validator.isISO8601(value)
14} 18}
@@ -78,6 +82,7 @@ function isFileValid (
78 82
79export { 83export {
80 exists, 84 exists,
85 isIntArray,
81 isArray, 86 isArray,
82 isIdValid, 87 isIdValid,
83 isUUIDValid, 88 isUUIDValid,
diff --git a/server/helpers/custom-validators/user-notifications.ts b/server/helpers/custom-validators/user-notifications.ts
new file mode 100644
index 000000000..4fb5d922d
--- /dev/null
+++ b/server/helpers/custom-validators/user-notifications.ts
@@ -0,0 +1,19 @@
1import { exists } from './misc'
2import * as validator from 'validator'
3import { UserNotificationType } from '../../../shared/models/users'
4import { UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
5
6function isUserNotificationTypeValid (value: any) {
7 return exists(value) && validator.isInt('' + value) && UserNotificationType[value] !== undefined
8}
9
10function isUserNotificationSettingValid (value: any) {
11 return exists(value) &&
12 validator.isInt('' + value) &&
13 UserNotificationSettingValue[ value ] !== undefined
14}
15
16export {
17 isUserNotificationSettingValid,
18 isUserNotificationTypeValid
19}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index c3df2383a..fcfaf71a0 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -50,7 +50,9 @@ const SORTABLE_COLUMNS = {
50 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], 50 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
51 51
52 ACCOUNTS_BLOCKLIST: [ 'createdAt' ], 52 ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
53 SERVERS_BLOCKLIST: [ 'createdAt' ] 53 SERVERS_BLOCKLIST: [ 'createdAt' ],
54
55 USER_NOTIFICATIONS: [ 'createdAt' ]
54} 56}
55 57
56const OAUTH_LIFETIME = { 58const OAUTH_LIFETIME = {
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 40cd659ab..84ad2079b 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -31,6 +31,8 @@ import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
31import { UserVideoHistoryModel } from '../models/account/user-video-history' 31import { UserVideoHistoryModel } from '../models/account/user-video-history'
32import { AccountBlocklistModel } from '../models/account/account-blocklist' 32import { AccountBlocklistModel } from '../models/account/account-blocklist'
33import { ServerBlocklistModel } from '../models/server/server-blocklist' 33import { ServerBlocklistModel } from '../models/server/server-blocklist'
34import { UserNotificationModel } from '../models/account/user-notification'
35import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
34 36
35require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 37require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
36 38
@@ -95,7 +97,9 @@ async function initDatabaseModels (silent: boolean) {
95 VideoRedundancyModel, 97 VideoRedundancyModel,
96 UserVideoHistoryModel, 98 UserVideoHistoryModel,
97 AccountBlocklistModel, 99 AccountBlocklistModel,
98 ServerBlocklistModel 100 ServerBlocklistModel,
101 UserNotificationModel,
102 UserNotificationSettingModel
99 ]) 103 ])
100 104
101 // Check extensions exist in the database 105 // Check extensions exist in the database
diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts
index cc88b5423..23310b41e 100644
--- a/server/lib/activitypub/process/process-announce.ts
+++ b/server/lib/activitypub/process/process-announce.ts
@@ -5,6 +5,8 @@ import { ActorModel } from '../../../models/activitypub/actor'
5import { VideoShareModel } from '../../../models/video/video-share' 5import { VideoShareModel } from '../../../models/video/video-share'
6import { forwardVideoRelatedActivity } from '../send/utils' 6import { forwardVideoRelatedActivity } from '../send/utils'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos' 7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { VideoPrivacy } from '../../../../shared/models/videos'
9import { Notifier } from '../../notifier'
8 10
9async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) { 11async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
10 return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity) 12 return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
@@ -21,9 +23,9 @@ export {
21async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) { 23async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
22 const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id 24 const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
23 25
24 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri }) 26 const { video, created: videoCreated } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
25 27
26 return sequelizeTypescript.transaction(async t => { 28 await sequelizeTypescript.transaction(async t => {
27 // Add share entry 29 // Add share entry
28 30
29 const share = { 31 const share = {
@@ -49,4 +51,6 @@ async function processVideoShare (actorAnnouncer: ActorModel, activity: Activity
49 51
50 return undefined 52 return undefined
51 }) 53 })
54
55 if (videoCreated) Notifier.Instance.notifyOnNewVideo(video)
52} 56}
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index df05ee452..2e04ee843 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -13,6 +13,7 @@ import { forwardVideoRelatedActivity } from '../send/utils'
13import { Redis } from '../../redis' 13import { Redis } from '../../redis'
14import { createOrUpdateCacheFile } from '../cache-file' 14import { createOrUpdateCacheFile } from '../cache-file'
15import { getVideoDislikeActivityPubUrl } from '../url' 15import { getVideoDislikeActivityPubUrl } from '../url'
16import { Notifier } from '../../notifier'
16 17
17async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { 18async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
18 const activityObject = activity.object 19 const activityObject = activity.object
@@ -47,7 +48,9 @@ export {
47async function processCreateVideo (activity: ActivityCreate) { 48async function processCreateVideo (activity: ActivityCreate) {
48 const videoToCreateData = activity.object as VideoTorrentObject 49 const videoToCreateData = activity.object as VideoTorrentObject
49 50
50 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData }) 51 const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
52
53 if (created) Notifier.Instance.notifyOnNewVideo(video)
51 54
52 return video 55 return video
53} 56}
@@ -133,7 +136,10 @@ async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateD
133 state: VideoAbuseState.PENDING 136 state: VideoAbuseState.PENDING
134 } 137 }
135 138
136 await VideoAbuseModel.create(videoAbuseData, { transaction: t }) 139 const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
140 videoAbuseInstance.Video = video
141
142 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
137 143
138 logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object) 144 logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object)
139 }) 145 })
@@ -147,7 +153,7 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
147 153
148 const { video } = await resolveThread(commentObject.inReplyTo) 154 const { video } = await resolveThread(commentObject.inReplyTo)
149 155
150 const { created } = await addVideoComment(video, commentObject.id) 156 const { comment, created } = await addVideoComment(video, commentObject.id)
151 157
152 if (video.isOwned() && created === true) { 158 if (video.isOwned() && created === true) {
153 // Don't resend the activity to the sender 159 // Don't resend the activity to the sender
@@ -155,4 +161,6 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
155 161
156 await forwardVideoRelatedActivity(activity, undefined, exceptions, video) 162 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
157 } 163 }
164
165 if (created === true) Notifier.Instance.notifyOnNewComment(comment)
158} 166}
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts
index 5868e7297..e87301fe7 100644
--- a/server/lib/activitypub/video-comments.ts
+++ b/server/lib/activitypub/video-comments.ts
@@ -70,7 +70,7 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
70 throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`) 70 throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`)
71 } 71 }
72 72
73 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 73 const actor = await getOrCreateActorAndServerAndModel(actorUrl, 'all')
74 const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body) 74 const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body)
75 if (!entry) return { created: false } 75 if (!entry) return { created: false }
76 76
@@ -80,6 +80,8 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
80 }, 80 },
81 defaults: entry 81 defaults: entry
82 }) 82 })
83 comment.Account = actor.Account
84 comment.Video = videoInstance
83 85
84 return { comment, created } 86 return { comment, created }
85} 87}
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 379c2a0d7..5794988a5 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -29,6 +29,7 @@ import { addVideoShares, shareVideoByServerAndChannel } from './share'
29import { AccountModel } from '../../models/account/account' 29import { AccountModel } from '../../models/account/account'
30import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video' 30import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
31import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub' 31import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
32import { Notifier } from '../notifier'
32 33
33async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 34async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
34 // If the video is not private and published, we federate it 35 // If the video is not private and published, we federate it
@@ -181,7 +182,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
181 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } }) 182 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
182 } 183 }
183 184
184 return { video: videoFromDatabase } 185 return { video: videoFromDatabase, created: false }
185 } 186 }
186 187
187 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl) 188 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
@@ -192,7 +193,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
192 193
193 await syncVideoExternalAttributes(video, fetchedVideo, syncParam) 194 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
194 195
195 return { video } 196 return { video, created: true }
196} 197}
197 198
198async function updateVideoFromAP (options: { 199async function updateVideoFromAP (options: {
@@ -213,6 +214,9 @@ async function updateVideoFromAP (options: {
213 214
214 videoFieldsSave = options.video.toJSON() 215 videoFieldsSave = options.video.toJSON()
215 216
217 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
218 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
219
216 // Check actor has the right to update the video 220 // Check actor has the right to update the video
217 const videoChannel = options.video.VideoChannel 221 const videoChannel = options.video.VideoChannel
218 if (videoChannel.Account.id !== options.account.id) { 222 if (videoChannel.Account.id !== options.account.id) {
@@ -277,6 +281,13 @@ async function updateVideoFromAP (options: {
277 }) 281 })
278 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises) 282 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
279 } 283 }
284
285 {
286 // Notify our users?
287 if (wasPrivateVideo || wasUnlistedVideo) {
288 Notifier.Instance.notifyOnNewVideo(options.video)
289 }
290 }
280 }) 291 })
281 292
282 logger.info('Remote video with uuid %s updated', options.videoObject.uuid) 293 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 2db3f8a34..1875ec1fc 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -115,8 +115,8 @@ export class ClientHtml {
115 } 115 }
116 116
117 private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) { 117 private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
118 const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName() 118 const previewUrl = CONFIG.WEBSERVER.URL + video.getPreviewStaticPath()
119 const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 119 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
120 120
121 const videoNameEscaped = escapeHTML(video.name) 121 const videoNameEscaped = escapeHTML(video.name)
122 const videoDescriptionEscaped = escapeHTML(video.description) 122 const videoDescriptionEscaped = escapeHTML(video.description)
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index 074d4ad44..d766e655b 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -1,5 +1,4 @@
1import { createTransport, Transporter } from 'nodemailer' 1import { createTransport, Transporter } from 'nodemailer'
2import { UserRight } from '../../shared/models/users'
3import { isTestInstance } from '../helpers/core-utils' 2import { isTestInstance } from '../helpers/core-utils'
4import { bunyanLogger, logger } from '../helpers/logger' 3import { bunyanLogger, logger } from '../helpers/logger'
5import { CONFIG } from '../initializers' 4import { CONFIG } from '../initializers'
@@ -8,6 +7,9 @@ import { VideoModel } from '../models/video/video'
8import { JobQueue } from './job-queue' 7import { JobQueue } from './job-queue'
9import { EmailPayload } from './job-queue/handlers/email' 8import { EmailPayload } from './job-queue/handlers/email'
10import { readFileSync } from 'fs-extra' 9import { readFileSync } from 'fs-extra'
10import { VideoCommentModel } from '../models/video/video-comment'
11import { VideoAbuseModel } from '../models/video/video-abuse'
12import { VideoBlacklistModel } from '../models/video/video-blacklist'
11 13
12class Emailer { 14class Emailer {
13 15
@@ -79,50 +81,57 @@ class Emailer {
79 } 81 }
80 } 82 }
81 83
82 addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { 84 addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) {
85 const channelName = video.VideoChannel.getDisplayName()
86 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
87
83 const text = `Hi dear user,\n\n` + 88 const text = `Hi dear user,\n\n` +
84 `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + 89 `Your subscription ${channelName} just published a new video: ${video.name}` +
85 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` + 90 `\n\n` +
86 `If you are not the person who initiated this request, please ignore this email.\n\n` + 91 `You can view it on ${videoUrl} ` +
92 `\n\n` +
87 `Cheers,\n` + 93 `Cheers,\n` +
88 `PeerTube.` 94 `PeerTube.`
89 95
90 const emailPayload: EmailPayload = { 96 const emailPayload: EmailPayload = {
91 to: [ to ], 97 to,
92 subject: 'Reset your PeerTube password', 98 subject: channelName + ' just published a new video',
93 text 99 text
94 } 100 }
95 101
96 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 102 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
97 } 103 }
98 104
99 addVerifyEmailJob (to: string, verifyEmailUrl: string) { 105 addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) {
100 const text = `Welcome to PeerTube,\n\n` + 106 const accountName = comment.Account.getDisplayName()
101 `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` + 107 const video = comment.Video
102 `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` + 108 const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
103 `If you are not the person who initiated this request, please ignore this email.\n\n` + 109
110 const text = `Hi dear user,\n\n` +
111 `A new comment has been posted by ${accountName} on your video ${video.name}` +
112 `\n\n` +
113 `You can view it on ${commentUrl} ` +
114 `\n\n` +
104 `Cheers,\n` + 115 `Cheers,\n` +
105 `PeerTube.` 116 `PeerTube.`
106 117
107 const emailPayload: EmailPayload = { 118 const emailPayload: EmailPayload = {
108 to: [ to ], 119 to,
109 subject: 'Verify your PeerTube email', 120 subject: 'New comment on your video ' + video.name,
110 text 121 text
111 } 122 }
112 123
113 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 124 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
114 } 125 }
115 126
116 async addVideoAbuseReportJob (videoId: number) { 127 async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
117 const video = await VideoModel.load(videoId) 128 const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
118 if (!video) throw new Error('Unknown Video id during Abuse report.')
119 129
120 const text = `Hi,\n\n` + 130 const text = `Hi,\n\n` +
121 `Your instance received an abuse for the following video ${video.url}\n\n` + 131 `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` +
122 `Cheers,\n` + 132 `Cheers,\n` +
123 `PeerTube.` 133 `PeerTube.`
124 134
125 const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES)
126 const emailPayload: EmailPayload = { 135 const emailPayload: EmailPayload = {
127 to, 136 to,
128 subject: '[PeerTube] Received a video abuse', 137 subject: '[PeerTube] Received a video abuse',
@@ -132,16 +141,12 @@ class Emailer {
132 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 141 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
133 } 142 }
134 143
135 async addVideoBlacklistReportJob (videoId: number, reason?: string) { 144 async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
136 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 145 const videoName = videoBlacklist.Video.name
137 if (!video) throw new Error('Unknown Video id during Blacklist report.') 146 const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
138 // It's not our user
139 if (video.remote === true) return
140 147
141 const user = await UserModel.loadById(video.VideoChannel.Account.userId) 148 const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
142 149 const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
143 const reasonString = reason ? ` for the following reason: ${reason}` : ''
144 const blockedString = `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
145 150
146 const text = 'Hi,\n\n' + 151 const text = 'Hi,\n\n' +
147 blockedString + 152 blockedString +
@@ -149,33 +154,26 @@ class Emailer {
149 'Cheers,\n' + 154 'Cheers,\n' +
150 `PeerTube.` 155 `PeerTube.`
151 156
152 const to = user.email
153 const emailPayload: EmailPayload = { 157 const emailPayload: EmailPayload = {
154 to: [ to ], 158 to,
155 subject: `[PeerTube] Video ${video.name} blacklisted`, 159 subject: `[PeerTube] Video ${videoName} blacklisted`,
156 text 160 text
157 } 161 }
158 162
159 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 163 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
160 } 164 }
161 165
162 async addVideoUnblacklistReportJob (videoId: number) { 166 async addVideoUnblacklistNotification (to: string[], video: VideoModel) {
163 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 167 const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
164 if (!video) throw new Error('Unknown Video id during Blacklist report.')
165 // It's not our user
166 if (video.remote === true) return
167
168 const user = await UserModel.loadById(video.VideoChannel.Account.userId)
169 168
170 const text = 'Hi,\n\n' + 169 const text = 'Hi,\n\n' +
171 `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + 170 `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
172 '\n\n' + 171 '\n\n' +
173 'Cheers,\n' + 172 'Cheers,\n' +
174 `PeerTube.` 173 `PeerTube.`
175 174
176 const to = user.email
177 const emailPayload: EmailPayload = { 175 const emailPayload: EmailPayload = {
178 to: [ to ], 176 to,
179 subject: `[PeerTube] Video ${video.name} unblacklisted`, 177 subject: `[PeerTube] Video ${video.name} unblacklisted`,
180 text 178 text
181 } 179 }
@@ -183,6 +181,40 @@ class Emailer {
183 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 181 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
184 } 182 }
185 183
184 addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
185 const text = `Hi dear user,\n\n` +
186 `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
187 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
188 `If you are not the person who initiated this request, please ignore this email.\n\n` +
189 `Cheers,\n` +
190 `PeerTube.`
191
192 const emailPayload: EmailPayload = {
193 to: [ to ],
194 subject: 'Reset your PeerTube password',
195 text
196 }
197
198 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
199 }
200
201 addVerifyEmailJob (to: string, verifyEmailUrl: string) {
202 const text = `Welcome to PeerTube,\n\n` +
203 `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
204 `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
205 `If you are not the person who initiated this request, please ignore this email.\n\n` +
206 `Cheers,\n` +
207 `PeerTube.`
208
209 const emailPayload: EmailPayload = {
210 to: [ to ],
211 subject: 'Verify your PeerTube email',
212 text
213 }
214
215 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
216 }
217
186 addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) { 218 addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) {
187 const reasonString = reason ? ` for the following reason: ${reason}` : '' 219 const reasonString = reason ? ` for the following reason: ${reason}` : ''
188 const blockedWord = blocked ? 'blocked' : 'unblocked' 220 const blockedWord = blocked ? 'blocked' : 'unblocked'
@@ -205,7 +237,7 @@ class Emailer {
205 } 237 }
206 238
207 sendMail (to: string[], subject: string, text: string) { 239 sendMail (to: string[], subject: string, text: string) {
208 if (!this.transporter) { 240 if (!this.enabled) {
209 throw new Error('Cannot send mail because SMTP is not configured.') 241 throw new Error('Cannot send mail because SMTP is not configured.')
210 } 242 }
211 243
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts
index 3dca2937f..959cc04fa 100644
--- a/server/lib/job-queue/handlers/video-file.ts
+++ b/server/lib/job-queue/handlers/video-file.ts
@@ -9,6 +9,7 @@ import { sequelizeTypescript } from '../../../initializers'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' 10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
11import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding' 11import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding'
12import { Notifier } from '../../notifier'
12 13
13export type VideoFilePayload = { 14export type VideoFilePayload = {
14 videoUUID: string 15 videoUUID: string
@@ -86,6 +87,7 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
86 87
87 // If the video was not published, we consider it is a new one for other instances 88 // If the video was not published, we consider it is a new one for other instances
88 await federateVideoIfNeeded(videoDatabase, isNewVideo, t) 89 await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
90 if (isNewVideo) Notifier.Instance.notifyOnNewVideo(video)
89 91
90 return undefined 92 return undefined
91 }) 93 })
@@ -134,7 +136,8 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo
134 logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy }) 136 logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
135 } 137 }
136 138
137 return federateVideoIfNeeded(videoDatabase, isNewVideo, t) 139 await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
140 if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
138 }) 141 })
139} 142}
140 143
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 63aacff98..82edb8d5c 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -15,6 +15,7 @@ import { VideoModel } from '../../../models/video/video'
15import { downloadWebTorrentVideo } from '../../../helpers/webtorrent' 15import { downloadWebTorrentVideo } from '../../../helpers/webtorrent'
16import { getSecureTorrentName } from '../../../helpers/utils' 16import { getSecureTorrentName } from '../../../helpers/utils'
17import { remove, move, stat } from 'fs-extra' 17import { remove, move, stat } from 'fs-extra'
18import { Notifier } from '../../notifier'
18 19
19type VideoImportYoutubeDLPayload = { 20type VideoImportYoutubeDLPayload = {
20 type: 'youtube-dl' 21 type: 'youtube-dl'
@@ -184,6 +185,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
184 // Now we can federate the video (reload from database, we need more attributes) 185 // Now we can federate the video (reload from database, we need more attributes)
185 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 186 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
186 await federateVideoIfNeeded(videoForFederation, true, t) 187 await federateVideoIfNeeded(videoForFederation, true, t)
188 Notifier.Instance.notifyOnNewVideo(videoForFederation)
187 189
188 // Update video import object 190 // Update video import object
189 videoImport.state = VideoImportState.SUCCESS 191 videoImport.state = VideoImportState.SUCCESS
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
new file mode 100644
index 000000000..a21b50b2d
--- /dev/null
+++ b/server/lib/notifier.ts
@@ -0,0 +1,235 @@
1import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
2import { logger } from '../helpers/logger'
3import { VideoModel } from '../models/video/video'
4import { Emailer } from './emailer'
5import { UserNotificationModel } from '../models/account/user-notification'
6import { VideoCommentModel } from '../models/video/video-comment'
7import { UserModel } from '../models/account/user'
8import { PeerTubeSocket } from './peertube-socket'
9import { CONFIG } from '../initializers/constants'
10import { VideoPrivacy, VideoState } from '../../shared/models/videos'
11import { VideoAbuseModel } from '../models/video/video-abuse'
12import { VideoBlacklistModel } from '../models/video/video-blacklist'
13import * as Bluebird from 'bluebird'
14
15class Notifier {
16
17 private static instance: Notifier
18
19 private constructor () {}
20
21 notifyOnNewVideo (video: VideoModel): void {
22 // Only notify on public and published videos
23 if (video.privacy !== VideoPrivacy.PUBLIC || video.state !== VideoState.PUBLISHED) return
24
25 this.notifySubscribersOfNewVideo(video)
26 .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
27 }
28
29 notifyOnNewComment (comment: VideoCommentModel): void {
30 this.notifyVideoOwnerOfNewComment(comment)
31 .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err }))
32 }
33
34 notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
35 this.notifyModeratorsOfNewVideoAbuse(videoAbuse)
36 .catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err }))
37 }
38
39 notifyOnVideoBlacklist (videoBlacklist: VideoBlacklistModel): void {
40 this.notifyVideoOwnerOfBlacklist(videoBlacklist)
41 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
42 }
43
44 notifyOnVideoUnblacklist (video: VideoModel): void {
45 this.notifyVideoOwnerOfUnblacklist(video)
46 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', video.url, { err }))
47 }
48
49 private async notifySubscribersOfNewVideo (video: VideoModel) {
50 // List all followers that are users
51 const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
52
53 logger.info('Notifying %d users of new video %s.', users.length, video.url)
54
55 function settingGetter (user: UserModel) {
56 return user.NotificationSetting.newVideoFromSubscription
57 }
58
59 async function notificationCreator (user: UserModel) {
60 const notification = await UserNotificationModel.create({
61 type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
62 userId: user.id,
63 videoId: video.id
64 })
65 notification.Video = video
66
67 return notification
68 }
69
70 function emailSender (emails: string[]) {
71 return Emailer.Instance.addNewVideoFromSubscriberNotification(emails, video)
72 }
73
74 return this.notify({ users, settingGetter, notificationCreator, emailSender })
75 }
76
77 private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
78 const user = await UserModel.loadByVideoId(comment.videoId)
79
80 // Not our user or user comments its own video
81 if (!user || comment.Account.userId === user.id) return
82
83 logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
84
85 function settingGetter (user: UserModel) {
86 return user.NotificationSetting.newCommentOnMyVideo
87 }
88
89 async function notificationCreator (user: UserModel) {
90 const notification = await UserNotificationModel.create({
91 type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
92 userId: user.id,
93 commentId: comment.id
94 })
95 notification.Comment = comment
96
97 return notification
98 }
99
100 function emailSender (emails: string[]) {
101 return Emailer.Instance.addNewCommentOnMyVideoNotification(emails, comment)
102 }
103
104 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
105 }
106
107 private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
108 const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
109 if (users.length === 0) return
110
111 logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url)
112
113 function settingGetter (user: UserModel) {
114 return user.NotificationSetting.videoAbuseAsModerator
115 }
116
117 async function notificationCreator (user: UserModel) {
118 const notification = await UserNotificationModel.create({
119 type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
120 userId: user.id,
121 videoAbuseId: videoAbuse.id
122 })
123 notification.VideoAbuse = videoAbuse
124
125 return notification
126 }
127
128 function emailSender (emails: string[]) {
129 return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
130 }
131
132 return this.notify({ users, settingGetter, notificationCreator, emailSender })
133 }
134
135 private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
136 const user = await UserModel.loadByVideoId(videoBlacklist.videoId)
137 if (!user) return
138
139 logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
140
141 function settingGetter (user: UserModel) {
142 return user.NotificationSetting.blacklistOnMyVideo
143 }
144
145 async function notificationCreator (user: UserModel) {
146 const notification = await UserNotificationModel.create({
147 type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
148 userId: user.id,
149 videoBlacklistId: videoBlacklist.id
150 })
151 notification.VideoBlacklist = videoBlacklist
152
153 return notification
154 }
155
156 function emailSender (emails: string[]) {
157 return Emailer.Instance.addVideoBlacklistNotification(emails, videoBlacklist)
158 }
159
160 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
161 }
162
163 private async notifyVideoOwnerOfUnblacklist (video: VideoModel) {
164 const user = await UserModel.loadByVideoId(video.id)
165 if (!user) return
166
167 logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
168
169 function settingGetter (user: UserModel) {
170 return user.NotificationSetting.blacklistOnMyVideo
171 }
172
173 async function notificationCreator (user: UserModel) {
174 const notification = await UserNotificationModel.create({
175 type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
176 userId: user.id,
177 videoId: video.id
178 })
179 notification.Video = video
180
181 return notification
182 }
183
184 function emailSender (emails: string[]) {
185 return Emailer.Instance.addVideoUnblacklistNotification(emails, video)
186 }
187
188 return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
189 }
190
191 private async notify (options: {
192 users: UserModel[],
193 notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
194 emailSender: (emails: string[]) => Promise<any> | Bluebird<any>,
195 settingGetter: (user: UserModel) => UserNotificationSettingValue
196 }) {
197 const emails: string[] = []
198
199 for (const user of options.users) {
200 if (this.isWebNotificationEnabled(options.settingGetter(user))) {
201 const notification = await options.notificationCreator(user)
202
203 PeerTubeSocket.Instance.sendNotification(user.id, notification)
204 }
205
206 if (this.isEmailEnabled(user, options.settingGetter(user))) {
207 emails.push(user.email)
208 }
209 }
210
211 if (emails.length !== 0) {
212 await options.emailSender(emails)
213 }
214 }
215
216 private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
217 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
218
219 return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
220 }
221
222 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
223 return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
224 }
225
226 static get Instance () {
227 return this.instance || (this.instance = new this())
228 }
229}
230
231// ---------------------------------------------------------------------------
232
233export {
234 Notifier
235}
diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts
index 5cbe60b82..2cd2ae97c 100644
--- a/server/lib/oauth-model.ts
+++ b/server/lib/oauth-model.ts
@@ -1,3 +1,4 @@
1import * as Bluebird from 'bluebird'
1import { AccessDeniedError } from 'oauth2-server' 2import { AccessDeniedError } from 'oauth2-server'
2import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
3import { UserModel } from '../models/account/user' 4import { UserModel } from '../models/account/user'
@@ -37,7 +38,7 @@ function clearCacheByToken (token: string) {
37function getAccessToken (bearerToken: string) { 38function getAccessToken (bearerToken: string) {
38 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').') 39 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
39 40
40 if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken] 41 if (accessTokenCache[bearerToken] !== undefined) return Bluebird.resolve(accessTokenCache[bearerToken])
41 42
42 return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken) 43 return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
43 .then(tokenModel => { 44 .then(tokenModel => {
diff --git a/server/lib/peertube-socket.ts b/server/lib/peertube-socket.ts
new file mode 100644
index 000000000..eb84ecd4b
--- /dev/null
+++ b/server/lib/peertube-socket.ts
@@ -0,0 +1,52 @@
1import * as SocketIO from 'socket.io'
2import { authenticateSocket } from '../middlewares'
3import { UserNotificationModel } from '../models/account/user-notification'
4import { logger } from '../helpers/logger'
5import { Server } from 'http'
6
7class PeerTubeSocket {
8
9 private static instance: PeerTubeSocket
10
11 private userNotificationSockets: { [ userId: number ]: SocketIO.Socket } = {}
12
13 private constructor () {}
14
15 init (server: Server) {
16 const io = SocketIO(server)
17
18 io.of('/user-notifications')
19 .use(authenticateSocket)
20 .on('connection', socket => {
21 const userId = socket.handshake.query.user.id
22
23 logger.debug('User %d connected on the notification system.', userId)
24
25 this.userNotificationSockets[userId] = socket
26
27 socket.on('disconnect', () => {
28 logger.debug('User %d disconnected from SocketIO notifications.', userId)
29
30 delete this.userNotificationSockets[userId]
31 })
32 })
33 }
34
35 sendNotification (userId: number, notification: UserNotificationModel) {
36 const socket = this.userNotificationSockets[userId]
37
38 if (!socket) return
39
40 socket.emit('new-notification', notification.toFormattedJSON())
41 }
42
43 static get Instance () {
44 return this.instance || (this.instance = new this())
45 }
46}
47
48// ---------------------------------------------------------------------------
49
50export {
51 PeerTubeSocket
52}
diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts
index 21f071f9e..b7fb029f1 100644
--- a/server/lib/schedulers/update-videos-scheduler.ts
+++ b/server/lib/schedulers/update-videos-scheduler.ts
@@ -5,6 +5,7 @@ import { retryTransactionWrapper } from '../../helpers/database-utils'
5import { federateVideoIfNeeded } from '../activitypub' 5import { federateVideoIfNeeded } from '../activitypub'
6import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers' 6import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
7import { VideoPrivacy } from '../../../shared/models/videos' 7import { VideoPrivacy } from '../../../shared/models/videos'
8import { Notifier } from '../notifier'
8 9
9export class UpdateVideosScheduler extends AbstractScheduler { 10export class UpdateVideosScheduler extends AbstractScheduler {
10 11
@@ -39,6 +40,10 @@ export class UpdateVideosScheduler extends AbstractScheduler {
39 40
40 await video.save({ transaction: t }) 41 await video.save({ transaction: t })
41 await federateVideoIfNeeded(video, isNewVideo, t) 42 await federateVideoIfNeeded(video, isNewVideo, t)
43
44 if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) {
45 Notifier.Instance.notifyOnNewVideo(video)
46 }
42 } 47 }
43 48
44 await schedule.destroy({ transaction: t }) 49 await schedule.destroy({ transaction: t })
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 29d6d087d..72127819c 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -9,6 +9,8 @@ import { createVideoChannel } from './video-channel'
9import { VideoChannelModel } from '../models/video/video-channel' 9import { VideoChannelModel } from '../models/video/video-channel'
10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' 10import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
11import { ActorModel } from '../models/activitypub/actor' 11import { ActorModel } from '../models/activitypub/actor'
12import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
13import { UserNotificationSettingValue } from '../../shared/models/users'
12 14
13async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { 15async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
14 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { 16 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@@ -18,6 +20,8 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse
18 } 20 }
19 21
20 const userCreated = await userToCreate.save(userOptions) 22 const userCreated = await userToCreate.save(userOptions)
23 userCreated.NotificationSetting = await createDefaultUserNotificationSettings(userCreated, t)
24
21 const accountCreated = await createLocalAccountWithoutKeys(userCreated.username, userCreated.id, null, t) 25 const accountCreated = await createLocalAccountWithoutKeys(userCreated.username, userCreated.id, null, t)
22 userCreated.Account = accountCreated 26 userCreated.Account = accountCreated
23 27
@@ -88,3 +92,15 @@ export {
88 createUserAccountAndChannel, 92 createUserAccountAndChannel,
89 createLocalAccountWithoutKeys 93 createLocalAccountWithoutKeys
90} 94}
95
96// ---------------------------------------------------------------------------
97
98function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
99 return UserNotificationSettingModel.create({
100 userId: user.id,
101 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
102 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
103 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
104 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
105 }, { transaction: t })
106}
diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts
index 8c1df2c3e..1d193d467 100644
--- a/server/middlewares/oauth.ts
+++ b/server/middlewares/oauth.ts
@@ -3,6 +3,8 @@ import * as OAuthServer from 'express-oauth-server'
3import 'express-validator' 3import 'express-validator'
4import { OAUTH_LIFETIME } from '../initializers' 4import { OAUTH_LIFETIME } from '../initializers'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { Socket } from 'socket.io'
7import { getAccessToken } from '../lib/oauth-model'
6 8
7const oAuthServer = new OAuthServer({ 9const oAuthServer = new OAuthServer({
8 useErrorHandler: true, 10 useErrorHandler: true,
@@ -28,6 +30,25 @@ function authenticate (req: express.Request, res: express.Response, next: expres
28 }) 30 })
29} 31}
30 32
33function authenticateSocket (socket: Socket, next: (err?: any) => void) {
34 const accessToken = socket.handshake.query.accessToken
35
36 logger.debug('Checking socket access token %s.', accessToken)
37
38 getAccessToken(accessToken)
39 .then(tokenDB => {
40 const now = new Date()
41
42 if (!tokenDB || tokenDB.accessTokenExpiresAt < now || tokenDB.refreshTokenExpiresAt < now) {
43 return next(new Error('Invalid access token.'))
44 }
45
46 socket.handshake.query.user = tokenDB.User
47
48 return next()
49 })
50}
51
31function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) { 52function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) {
32 return new Promise(resolve => { 53 return new Promise(resolve => {
33 // Already authenticated? (or tried to) 54 // Already authenticated? (or tried to)
@@ -68,6 +89,7 @@ function token (req: express.Request, res: express.Response, next: express.NextF
68 89
69export { 90export {
70 authenticate, 91 authenticate,
92 authenticateSocket,
71 authenticatePromiseIfNeeded, 93 authenticatePromiseIfNeeded,
72 optionalAuthenticate, 94 optionalAuthenticate,
73 token 95 token
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 4c0577d8f..5ceda845f 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -18,6 +18,7 @@ const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOW
18const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) 18const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
19const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST) 19const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
20const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST) 20const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
21const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
21 22
22const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 23const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
23const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 24const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -35,6 +36,7 @@ const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS)
35const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS) 36const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
36const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS) 37const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
37const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS) 38const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
39const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
38 40
39// --------------------------------------------------------------------------- 41// ---------------------------------------------------------------------------
40 42
@@ -54,5 +56,6 @@ export {
54 userSubscriptionsSortValidator, 56 userSubscriptionsSortValidator,
55 videoChannelsSearchSortValidator, 57 videoChannelsSearchSortValidator,
56 accountsBlocklistSortValidator, 58 accountsBlocklistSortValidator,
57 serversBlocklistSortValidator 59 serversBlocklistSortValidator,
60 userNotificationsSortValidator
58} 61}
diff --git a/server/middlewares/validators/user-history.ts b/server/middlewares/validators/user-history.ts
index 3c8971ea1..418313d09 100644
--- a/server/middlewares/validators/user-history.ts
+++ b/server/middlewares/validators/user-history.ts
@@ -1,13 +1,9 @@
1import * as express from 'express' 1import * as express from 'express'
2import 'express-validator' 2import 'express-validator'
3import { body, param, query } from 'express-validator/check' 3import { body } from 'express-validator/check'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './utils'
6import { ActorFollowModel } from '../../models/activitypub/actor-follow' 6import { isDateValid } from '../../helpers/custom-validators/misc'
7import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
8import { UserModel } from '../../models/account/user'
9import { CONFIG } from '../../initializers'
10import { isDateValid, toArray } from '../../helpers/custom-validators/misc'
11 7
12const userHistoryRemoveValidator = [ 8const userHistoryRemoveValidator = [
13 body('beforeDate') 9 body('beforeDate')
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
new file mode 100644
index 000000000..8202f307e
--- /dev/null
+++ b/server/middlewares/validators/user-notifications.ts
@@ -0,0 +1,46 @@
1import * as express from 'express'
2import 'express-validator'
3import { body } from 'express-validator/check'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
7import { isIntArray } from '../../helpers/custom-validators/misc'
8
9const updateNotificationSettingsValidator = [
10 body('newVideoFromSubscription')
11 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
12 body('newCommentOnMyVideo')
13 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
14 body('videoAbuseAsModerator')
15 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'),
16 body('blacklistOnMyVideo')
17 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new blacklist on my video notification setting'),
18
19 (req: express.Request, res: express.Response, next: express.NextFunction) => {
20 logger.debug('Checking updateNotificationSettingsValidator parameters', { parameters: req.body })
21
22 if (areValidationErrors(req, res)) return
23
24 return next()
25 }
26]
27
28const markAsReadUserNotificationsValidator = [
29 body('ids')
30 .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'),
31
32 (req: express.Request, res: express.Response, next: express.NextFunction) => {
33 logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
34
35 if (areValidationErrors(req, res)) return
36
37 return next()
38 }
39]
40
41// ---------------------------------------------------------------------------
42
43export {
44 updateNotificationSettingsValidator,
45 markAsReadUserNotificationsValidator
46}
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
new file mode 100644
index 000000000..bc24b1e33
--- /dev/null
+++ b/server/models/account/user-notification-setting.ts
@@ -0,0 +1,100 @@
1import {
2 AfterDestroy,
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils'
16import { UserModel } from './user'
17import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
19import { clearCacheByUserId } from '../../lib/oauth-model'
20
21@Table({
22 tableName: 'userNotificationSetting',
23 indexes: [
24 {
25 fields: [ 'userId' ],
26 unique: true
27 }
28 ]
29})
30export class UserNotificationSettingModel extends Model<UserNotificationSettingModel> {
31
32 @AllowNull(false)
33 @Default(null)
34 @Is(
35 'UserNotificationSettingNewVideoFromSubscription',
36 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
37 )
38 @Column
39 newVideoFromSubscription: UserNotificationSettingValue
40
41 @AllowNull(false)
42 @Default(null)
43 @Is(
44 'UserNotificationSettingNewCommentOnMyVideo',
45 value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
46 )
47 @Column
48 newCommentOnMyVideo: UserNotificationSettingValue
49
50 @AllowNull(false)
51 @Default(null)
52 @Is(
53 'UserNotificationSettingVideoAbuseAsModerator',
54 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
55 )
56 @Column
57 videoAbuseAsModerator: UserNotificationSettingValue
58
59 @AllowNull(false)
60 @Default(null)
61 @Is(
62 'UserNotificationSettingBlacklistOnMyVideo',
63 value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
64 )
65 @Column
66 blacklistOnMyVideo: UserNotificationSettingValue
67
68 @ForeignKey(() => UserModel)
69 @Column
70 userId: number
71
72 @BelongsTo(() => UserModel, {
73 foreignKey: {
74 allowNull: false
75 },
76 onDelete: 'cascade'
77 })
78 User: UserModel
79
80 @CreatedAt
81 createdAt: Date
82
83 @UpdatedAt
84 updatedAt: Date
85
86 @AfterUpdate
87 @AfterDestroy
88 static removeTokenCache (instance: UserNotificationSettingModel) {
89 return clearCacheByUserId(instance.userId)
90 }
91
92 toFormattedJSON (): UserNotificationSetting {
93 return {
94 newCommentOnMyVideo: this.newCommentOnMyVideo,
95 newVideoFromSubscription: this.newVideoFromSubscription,
96 videoAbuseAsModerator: this.videoAbuseAsModerator,
97 blacklistOnMyVideo: this.blacklistOnMyVideo
98 }
99 }
100}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
new file mode 100644
index 000000000..e22f0d57f
--- /dev/null
+++ b/server/models/account/user-notification.ts
@@ -0,0 +1,256 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { UserNotification, UserNotificationType } from '../../../shared'
3import { getSort, throwIfNotValid } from '../utils'
4import { isBooleanValid } from '../../helpers/custom-validators/misc'
5import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
6import { UserModel } from './user'
7import { VideoModel } from '../video/video'
8import { VideoCommentModel } from '../video/video-comment'
9import { Op } from 'sequelize'
10import { VideoChannelModel } from '../video/video-channel'
11import { AccountModel } from './account'
12import { VideoAbuseModel } from '../video/video-abuse'
13import { VideoBlacklistModel } from '../video/video-blacklist'
14
15enum ScopeNames {
16 WITH_ALL = 'WITH_ALL'
17}
18
19@Scopes({
20 [ScopeNames.WITH_ALL]: {
21 include: [
22 {
23 attributes: [ 'id', 'uuid', 'name' ],
24 model: () => VideoModel.unscoped(),
25 required: false,
26 include: [
27 {
28 required: true,
29 attributes: [ 'id', 'name' ],
30 model: () => VideoChannelModel.unscoped()
31 }
32 ]
33 },
34 {
35 attributes: [ 'id' ],
36 model: () => VideoCommentModel.unscoped(),
37 required: false,
38 include: [
39 {
40 required: true,
41 attributes: [ 'id', 'name' ],
42 model: () => AccountModel.unscoped()
43 },
44 {
45 required: true,
46 attributes: [ 'id', 'uuid', 'name' ],
47 model: () => VideoModel.unscoped()
48 }
49 ]
50 },
51 {
52 attributes: [ 'id' ],
53 model: () => VideoAbuseModel.unscoped(),
54 required: false,
55 include: [
56 {
57 required: true,
58 attributes: [ 'id', 'uuid', 'name' ],
59 model: () => VideoModel.unscoped()
60 }
61 ]
62 },
63 {
64 attributes: [ 'id' ],
65 model: () => VideoBlacklistModel.unscoped(),
66 required: false,
67 include: [
68 {
69 required: true,
70 attributes: [ 'id', 'uuid', 'name' ],
71 model: () => VideoModel.unscoped()
72 }
73 ]
74 }
75 ]
76 }
77})
78@Table({
79 tableName: 'userNotification',
80 indexes: [
81 {
82 fields: [ 'videoId' ]
83 },
84 {
85 fields: [ 'commentId' ]
86 }
87 ]
88})
89export class UserNotificationModel extends Model<UserNotificationModel> {
90
91 @AllowNull(false)
92 @Default(null)
93 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
94 @Column
95 type: UserNotificationType
96
97 @AllowNull(false)
98 @Default(false)
99 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
100 @Column
101 read: boolean
102
103 @CreatedAt
104 createdAt: Date
105
106 @UpdatedAt
107 updatedAt: Date
108
109 @ForeignKey(() => UserModel)
110 @Column
111 userId: number
112
113 @BelongsTo(() => UserModel, {
114 foreignKey: {
115 allowNull: false
116 },
117 onDelete: 'cascade'
118 })
119 User: UserModel
120
121 @ForeignKey(() => VideoModel)
122 @Column
123 videoId: number
124
125 @BelongsTo(() => VideoModel, {
126 foreignKey: {
127 allowNull: true
128 },
129 onDelete: 'cascade'
130 })
131 Video: VideoModel
132
133 @ForeignKey(() => VideoCommentModel)
134 @Column
135 commentId: number
136
137 @BelongsTo(() => VideoCommentModel, {
138 foreignKey: {
139 allowNull: true
140 },
141 onDelete: 'cascade'
142 })
143 Comment: VideoCommentModel
144
145 @ForeignKey(() => VideoAbuseModel)
146 @Column
147 videoAbuseId: number
148
149 @BelongsTo(() => VideoAbuseModel, {
150 foreignKey: {
151 allowNull: true
152 },
153 onDelete: 'cascade'
154 })
155 VideoAbuse: VideoAbuseModel
156
157 @ForeignKey(() => VideoBlacklistModel)
158 @Column
159 videoBlacklistId: number
160
161 @BelongsTo(() => VideoBlacklistModel, {
162 foreignKey: {
163 allowNull: true
164 },
165 onDelete: 'cascade'
166 })
167 VideoBlacklist: VideoBlacklistModel
168
169 static listForApi (userId: number, start: number, count: number, sort: string) {
170 const query = {
171 offset: start,
172 limit: count,
173 order: getSort(sort),
174 where: {
175 userId
176 }
177 }
178
179 return UserNotificationModel.scope(ScopeNames.WITH_ALL)
180 .findAndCountAll(query)
181 .then(({ rows, count }) => {
182 return {
183 data: rows,
184 total: count
185 }
186 })
187 }
188
189 static markAsRead (userId: number, notificationIds: number[]) {
190 const query = {
191 where: {
192 userId,
193 id: {
194 [Op.any]: notificationIds
195 }
196 }
197 }
198
199 return UserNotificationModel.update({ read: true }, query)
200 }
201
202 toFormattedJSON (): UserNotification {
203 const video = this.Video ? {
204 id: this.Video.id,
205 uuid: this.Video.uuid,
206 name: this.Video.name,
207 channel: {
208 id: this.Video.VideoChannel.id,
209 displayName: this.Video.VideoChannel.getDisplayName()
210 }
211 } : undefined
212
213 const comment = this.Comment ? {
214 id: this.Comment.id,
215 account: {
216 id: this.Comment.Account.id,
217 displayName: this.Comment.Account.getDisplayName()
218 },
219 video: {
220 id: this.Comment.Video.id,
221 uuid: this.Comment.Video.uuid,
222 name: this.Comment.Video.name
223 }
224 } : undefined
225
226 const videoAbuse = this.VideoAbuse ? {
227 id: this.VideoAbuse.id,
228 video: {
229 id: this.VideoAbuse.Video.id,
230 uuid: this.VideoAbuse.Video.uuid,
231 name: this.VideoAbuse.Video.name
232 }
233 } : undefined
234
235 const videoBlacklist = this.VideoBlacklist ? {
236 id: this.VideoBlacklist.id,
237 video: {
238 id: this.VideoBlacklist.Video.id,
239 uuid: this.VideoBlacklist.Video.uuid,
240 name: this.VideoBlacklist.Video.name
241 }
242 } : undefined
243
244 return {
245 id: this.id,
246 type: this.type,
247 read: this.read,
248 video,
249 comment,
250 videoAbuse,
251 videoBlacklist,
252 createdAt: this.createdAt.toISOString(),
253 updatedAt: this.updatedAt.toISOString()
254 }
255 }
256}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 180ced810..55ec14d05 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -32,8 +32,8 @@ import {
32 isUserUsernameValid, 32 isUserUsernameValid,
33 isUserVideoQuotaDailyValid, 33 isUserVideoQuotaDailyValid,
34 isUserVideoQuotaValid, 34 isUserVideoQuotaValid,
35 isUserWebTorrentEnabledValid, 35 isUserVideosHistoryEnabledValid,
36 isUserVideosHistoryEnabledValid 36 isUserWebTorrentEnabledValid
37} from '../../helpers/custom-validators/users' 37} from '../../helpers/custom-validators/users'
38import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' 38import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
39import { OAuthTokenModel } from '../oauth/oauth-token' 39import { OAuthTokenModel } from '../oauth/oauth-token'
@@ -44,6 +44,10 @@ import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
44import { values } from 'lodash' 44import { values } from 'lodash'
45import { NSFW_POLICY_TYPES } from '../../initializers' 45import { NSFW_POLICY_TYPES } from '../../initializers'
46import { clearCacheByUserId } from '../../lib/oauth-model' 46import { clearCacheByUserId } from '../../lib/oauth-model'
47import { UserNotificationSettingModel } from './user-notification-setting'
48import { VideoModel } from '../video/video'
49import { ActorModel } from '../activitypub/actor'
50import { ActorFollowModel } from '../activitypub/actor-follow'
47 51
48enum ScopeNames { 52enum ScopeNames {
49 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' 53 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -54,6 +58,10 @@ enum ScopeNames {
54 { 58 {
55 model: () => AccountModel, 59 model: () => AccountModel,
56 required: true 60 required: true
61 },
62 {
63 model: () => UserNotificationSettingModel,
64 required: true
57 } 65 }
58 ] 66 ]
59}) 67})
@@ -64,6 +72,10 @@ enum ScopeNames {
64 model: () => AccountModel, 72 model: () => AccountModel,
65 required: true, 73 required: true,
66 include: [ () => VideoChannelModel ] 74 include: [ () => VideoChannelModel ]
75 },
76 {
77 model: () => UserNotificationSettingModel,
78 required: true
67 } 79 }
68 ] 80 ]
69 } 81 }
@@ -167,6 +179,13 @@ export class UserModel extends Model<UserModel> {
167 }) 179 })
168 Account: AccountModel 180 Account: AccountModel
169 181
182 @HasOne(() => UserNotificationSettingModel, {
183 foreignKey: 'userId',
184 onDelete: 'cascade',
185 hooks: true
186 })
187 NotificationSetting: UserNotificationSettingModel
188
170 @HasMany(() => OAuthTokenModel, { 189 @HasMany(() => OAuthTokenModel, {
171 foreignKey: 'userId', 190 foreignKey: 'userId',
172 onDelete: 'cascade' 191 onDelete: 'cascade'
@@ -249,13 +268,12 @@ export class UserModel extends Model<UserModel> {
249 }) 268 })
250 } 269 }
251 270
252 static listEmailsWithRight (right: UserRight) { 271 static listWithRight (right: UserRight) {
253 const roles = Object.keys(USER_ROLE_LABELS) 272 const roles = Object.keys(USER_ROLE_LABELS)
254 .map(k => parseInt(k, 10) as UserRole) 273 .map(k => parseInt(k, 10) as UserRole)
255 .filter(role => hasUserRight(role, right)) 274 .filter(role => hasUserRight(role, right))
256 275
257 const query = { 276 const query = {
258 attribute: [ 'email' ],
259 where: { 277 where: {
260 role: { 278 role: {
261 [Sequelize.Op.in]: roles 279 [Sequelize.Op.in]: roles
@@ -263,9 +281,46 @@ export class UserModel extends Model<UserModel> {
263 } 281 }
264 } 282 }
265 283
266 return UserModel.unscoped() 284 return UserModel.findAll(query)
267 .findAll(query) 285 }
268 .then(u => u.map(u => u.email)) 286
287 static listUserSubscribersOf (actorId: number) {
288 const query = {
289 include: [
290 {
291 model: UserNotificationSettingModel.unscoped(),
292 required: true
293 },
294 {
295 attributes: [ 'userId' ],
296 model: AccountModel.unscoped(),
297 required: true,
298 include: [
299 {
300 attributes: [ ],
301 model: ActorModel.unscoped(),
302 required: true,
303 where: {
304 serverId: null
305 },
306 include: [
307 {
308 attributes: [ ],
309 as: 'ActorFollowings',
310 model: ActorFollowModel.unscoped(),
311 required: true,
312 where: {
313 targetActorId: actorId
314 }
315 }
316 ]
317 }
318 ]
319 }
320 ]
321 }
322
323 return UserModel.unscoped().findAll(query)
269 } 324 }
270 325
271 static loadById (id: number) { 326 static loadById (id: number) {
@@ -314,6 +369,37 @@ export class UserModel extends Model<UserModel> {
314 return UserModel.findOne(query) 369 return UserModel.findOne(query)
315 } 370 }
316 371
372 static loadByVideoId (videoId: number) {
373 const query = {
374 include: [
375 {
376 required: true,
377 attributes: [ 'id' ],
378 model: AccountModel.unscoped(),
379 include: [
380 {
381 required: true,
382 attributes: [ 'id' ],
383 model: VideoChannelModel.unscoped(),
384 include: [
385 {
386 required: true,
387 attributes: [ 'id' ],
388 model: VideoModel.unscoped(),
389 where: {
390 id: videoId
391 }
392 }
393 ]
394 }
395 ]
396 }
397 ]
398 }
399
400 return UserModel.findOne(query)
401 }
402
317 static getOriginalVideoFileTotalFromUser (user: UserModel) { 403 static getOriginalVideoFileTotalFromUser (user: UserModel) {
318 // Don't use sequelize because we need to use a sub query 404 // Don't use sequelize because we need to use a sub query
319 const query = UserModel.generateUserQuotaBaseSQL() 405 const query = UserModel.generateUserQuotaBaseSQL()
@@ -380,6 +466,7 @@ export class UserModel extends Model<UserModel> {
380 blocked: this.blocked, 466 blocked: this.blocked,
381 blockedReason: this.blockedReason, 467 blockedReason: this.blockedReason,
382 account: this.Account.toFormattedJSON(), 468 account: this.Account.toFormattedJSON(),
469 notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
383 videoChannels: [], 470 videoChannels: [],
384 videoQuotaUsed: videoQuotaUsed !== undefined 471 videoQuotaUsed: videoQuotaUsed !== undefined
385 ? parseInt(videoQuotaUsed, 10) 472 ? parseInt(videoQuotaUsed, 10)
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts
index 994f791de..796e07a42 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/activitypub/actor-follow.ts
@@ -307,7 +307,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
307 }) 307 })
308 } 308 }
309 309
310 static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) { 310 static listFollowersForApi (actorId: number, start: number, count: number, sort: string, search?: string) {
311 const query = { 311 const query = {
312 distinct: true, 312 distinct: true,
313 offset: start, 313 offset: start,
@@ -335,7 +335,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
335 as: 'ActorFollowing', 335 as: 'ActorFollowing',
336 required: true, 336 required: true,
337 where: { 337 where: {
338 id 338 id: actorId
339 } 339 }
340 } 340 }
341 ] 341 ]
@@ -350,7 +350,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
350 }) 350 })
351 } 351 }
352 352
353 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { 353 static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
354 const query = { 354 const query = {
355 attributes: [], 355 attributes: [],
356 distinct: true, 356 distinct: true,
@@ -358,7 +358,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
358 limit: count, 358 limit: count,
359 order: getSort(sort), 359 order: getSort(sort),
360 where: { 360 where: {
361 actorId: id 361 actorId: actorId
362 }, 362 },
363 include: [ 363 include: [
364 { 364 {
@@ -451,9 +451,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
451 static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) { 451 static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) {
452 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` + 452 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
453 'WHERE id IN (' + 453 'WHERE id IN (' +
454 'SELECT "actorFollow"."id" FROM "actorFollow" ' + 454 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
455 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' + 455 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
456 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` + 456 `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
457 ')' 457 ')'
458 458
459 const options = { 459 const options = {
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index 12b83916e..dda57a8ba 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -219,6 +219,7 @@ export class ActorModel extends Model<ActorModel> {
219 name: 'actorId', 219 name: 'actorId',
220 allowNull: false 220 allowNull: false
221 }, 221 },
222 as: 'ActorFollowings',
222 onDelete: 'cascade' 223 onDelete: 'cascade'
223 }) 224 })
224 ActorFollowing: ActorFollowModel[] 225 ActorFollowing: ActorFollowModel[]
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index dbb88ca45..4c9e2d05e 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -86,11 +86,6 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
86 }) 86 })
87 Video: VideoModel 87 Video: VideoModel
88 88
89 @AfterCreate
90 static sendEmailNotification (instance: VideoAbuseModel) {
91 return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
92 }
93
94 static loadByIdAndVideoId (id: number, videoId: number) { 89 static loadByIdAndVideoId (id: number, videoId: number) {
95 const query = { 90 const query = {
96 where: { 91 where: {
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 67f7cd487..23e992685 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -53,16 +53,6 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
53 }) 53 })
54 Video: VideoModel 54 Video: VideoModel
55 55
56 @AfterCreate
57 static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
58 return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
59 }
60
61 @AfterDestroy
62 static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
63 return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
64 }
65
66 static listForApi (start: number, count: number, sort: SortType) { 56 static listForApi (start: number, count: number, sort: SortType) {
67 const query = { 57 const query = {
68 offset: start, 58 offset: start,
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index dd6d08139..d8fc2a564 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -448,6 +448,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
448 } 448 }
449 } 449 }
450 450
451 getCommentStaticPath () {
452 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
453 }
454
451 getThreadId (): number { 455 getThreadId (): number {
452 return this.originCommentId || this.id 456 return this.originCommentId || this.id
453 } 457 }
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index bcf327f32..fc200e5d1 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1527,6 +1527,10 @@ export class VideoModel extends Model<VideoModel> {
1527 videoFile.infoHash = parsedTorrent.infoHash 1527 videoFile.infoHash = parsedTorrent.infoHash
1528 } 1528 }
1529 1529
1530 getWatchStaticPath () {
1531 return '/videos/watch/' + this.uuid
1532 }
1533
1530 getEmbedStaticPath () { 1534 getEmbedStaticPath () {
1531 return '/videos/embed/' + this.uuid 1535 return '/videos/embed/' + this.uuid
1532 } 1536 }
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 877ceb0a7..7a181d1d6 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -7,6 +7,7 @@ import './jobs'
7import './redundancy' 7import './redundancy'
8import './search' 8import './search'
9import './services' 9import './services'
10import './user-notifications'
10import './user-subscriptions' 11import './user-subscriptions'
11import './users' 12import './users'
12import './video-abuses' 13import './video-abuses'
diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts
new file mode 100644
index 000000000..3ae36ddb3
--- /dev/null
+++ b/server/tests/api/check-params/user-notifications.ts
@@ -0,0 +1,249 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4import * as io from 'socket.io-client'
5
6import {
7 flushTests,
8 immutableAssign,
9 killallServers,
10 makeGetRequest,
11 makePostBodyRequest,
12 makePutBodyRequest,
13 runServer,
14 ServerInfo,
15 setAccessTokensToServers,
16 wait
17} from '../../../../shared/utils'
18import {
19 checkBadCountPagination,
20 checkBadSortPagination,
21 checkBadStartPagination
22} from '../../../../shared/utils/requests/check-api-params'
23import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users'
24
25describe('Test user notifications API validators', function () {
26 let server: ServerInfo
27
28 // ---------------------------------------------------------------
29
30 before(async function () {
31 this.timeout(30000)
32
33 await flushTests()
34
35 server = await runServer(1)
36
37 await setAccessTokensToServers([ server ])
38 })
39
40 describe('When listing my notifications', function () {
41 const path = '/api/v1/users/me/notifications'
42
43 it('Should fail with a bad start pagination', async function () {
44 await checkBadStartPagination(server.url, path, server.accessToken)
45 })
46
47 it('Should fail with a bad count pagination', async function () {
48 await checkBadCountPagination(server.url, path, server.accessToken)
49 })
50
51 it('Should fail with an incorrect sort', async function () {
52 await checkBadSortPagination(server.url, path, server.accessToken)
53 })
54
55 it('Should fail with a non authenticated user', async function () {
56 await makeGetRequest({
57 url: server.url,
58 path,
59 statusCodeExpected: 401
60 })
61 })
62
63 it('Should succeed with the correct parameters', async function () {
64 await makeGetRequest({
65 url: server.url,
66 path,
67 token: server.accessToken,
68 statusCodeExpected: 200
69 })
70 })
71 })
72
73 describe('When marking as read my notifications', function () {
74 const path = '/api/v1/users/me/notifications/read'
75
76 it('Should fail with wrong ids parameters', async function () {
77 await makePostBodyRequest({
78 url: server.url,
79 path,
80 fields: {
81 ids: [ 'hello' ]
82 },
83 token: server.accessToken,
84 statusCodeExpected: 400
85 })
86
87 await makePostBodyRequest({
88 url: server.url,
89 path,
90 fields: {
91 ids: 5
92 },
93 token: server.accessToken,
94 statusCodeExpected: 400
95 })
96 })
97
98 it('Should fail with a non authenticated user', async function () {
99 await makePostBodyRequest({
100 url: server.url,
101 path,
102 fields: {
103 ids: [ 5 ]
104 },
105 statusCodeExpected: 401
106 })
107 })
108
109 it('Should succeed with the correct parameters', async function () {
110 await makePostBodyRequest({
111 url: server.url,
112 path,
113 fields: {
114 ids: [ 5 ]
115 },
116 token: server.accessToken,
117 statusCodeExpected: 204
118 })
119 })
120 })
121
122 describe('When updating my notification settings', function () {
123 const path = '/api/v1/users/me/notification-settings'
124 const correctFields: UserNotificationSetting = {
125 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
126 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
127 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
128 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION
129 }
130
131 it('Should fail with missing fields', async function () {
132 await makePutBodyRequest({
133 url: server.url,
134 path,
135 token: server.accessToken,
136 fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION },
137 statusCodeExpected: 400
138 })
139 })
140
141 it('Should fail with incorrect field values', async function () {
142 {
143 const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 15 })
144
145 await makePutBodyRequest({
146 url: server.url,
147 path,
148 token: server.accessToken,
149 fields,
150 statusCodeExpected: 400
151 })
152 }
153
154 {
155 const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 'toto' })
156
157 await makePutBodyRequest({
158 url: server.url,
159 path,
160 fields,
161 token: server.accessToken,
162 statusCodeExpected: 400
163 })
164 }
165 })
166
167 it('Should fail with a non authenticated user', async function () {
168 await makePutBodyRequest({
169 url: server.url,
170 path,
171 fields: correctFields,
172 statusCodeExpected: 401
173 })
174 })
175
176 it('Should succeed with the correct parameters', async function () {
177 await makePutBodyRequest({
178 url: server.url,
179 path,
180 token: server.accessToken,
181 fields: correctFields,
182 statusCodeExpected: 204
183 })
184 })
185 })
186
187 describe('When connecting to my notification socket', function () {
188 it('Should fail with no token', function (next) {
189 const socket = io('http://localhost:9001/user-notifications', { reconnection: false })
190
191 socket.on('error', () => {
192 socket.removeListener('error', this)
193 socket.disconnect()
194 next()
195 })
196
197 socket.on('connect', () => {
198 socket.disconnect()
199 next(new Error('Connected with a missing token.'))
200 })
201 })
202
203 it('Should fail with an invalid token', function (next) {
204 const socket = io('http://localhost:9001/user-notifications', {
205 query: { accessToken: 'bad_access_token' },
206 reconnection: false
207 })
208
209 socket.on('error', () => {
210 socket.removeListener('error', this)
211 socket.disconnect()
212 next()
213 })
214
215 socket.on('connect', () => {
216 socket.disconnect()
217 next(new Error('Connected with an invalid token.'))
218 })
219 })
220
221 it('Should success with the correct token', function (next) {
222 const socket = io('http://localhost:9001/user-notifications', {
223 query: { accessToken: server.accessToken },
224 reconnection: false
225 })
226
227 const errorListener = socket.on('error', err => {
228 next(new Error('Error in connection: ' + err))
229 })
230
231 socket.on('connect', async () => {
232 socket.removeListener('error', errorListener)
233 socket.disconnect()
234
235 await wait(500)
236 next()
237 })
238 })
239 })
240
241 after(async function () {
242 killallServers([ server ])
243
244 // Keep the logs if the test failed
245 if (this['ok']) {
246 await flushTests()
247 }
248 })
249})
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index ff433315d..63e6e827a 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,5 +1,6 @@
1import './blocklist' 1import './blocklist'
2import './user-subscriptions' 2import './user-subscriptions'
3import './user-notifications'
3import './users' 4import './users'
4import './users-multiple-servers' 5import './users-multiple-servers'
5import './users-verification' 6import './users-verification'
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
new file mode 100644
index 000000000..ea35e6390
--- /dev/null
+++ b/server/tests/api/users/user-notifications.ts
@@ -0,0 +1,628 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 addVideoToBlacklist,
7 createUser,
8 doubleFollow,
9 flushAndRunMultipleServers,
10 flushTests,
11 getMyUserInformation,
12 immutableAssign,
13 removeVideoFromBlacklist,
14 reportVideoAbuse,
15 updateVideo,
16 userLogin,
17 wait
18} from '../../../../shared/utils'
19import { killallServers, ServerInfo, uploadVideo } from '../../../../shared/utils/index'
20import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
21import { waitJobs } from '../../../../shared/utils/server/jobs'
22import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
23import {
24 CheckerBaseParams,
25 checkNewBlacklistOnMyVideo,
26 checkNewCommentOnMyVideo,
27 checkNewVideoAbuseForModerators,
28 checkNewVideoFromSubscription,
29 getLastNotification,
30 getUserNotifications,
31 markAsReadNotifications,
32 updateMyNotificationSettings
33} from '../../../../shared/utils/users/user-notifications'
34import { User, UserNotification, UserNotificationSettingValue } from '../../../../shared/models/users'
35import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
36import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
37import { VideoPrivacy } from '../../../../shared/models/videos'
38import { getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
39import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
40
41const expect = chai.expect
42
43async function uploadVideoByRemoteAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
44 const data = Object.assign({ name: 'remote video ' + videoNameId }, additionalParams)
45 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, data)
46
47 await waitJobs(servers)
48
49 return res.body.video.uuid
50}
51
52async function uploadVideoByLocalAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
53 const data = Object.assign({ name: 'local video ' + videoNameId }, additionalParams)
54 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, data)
55
56 await waitJobs(servers)
57
58 return res.body.video.uuid
59}
60
61describe('Test users notifications', function () {
62 let servers: ServerInfo[] = []
63 let userAccessToken: string
64 let userNotifications: UserNotification[] = []
65 let adminNotifications: UserNotification[] = []
66 const emails: object[] = []
67
68 before(async function () {
69 this.timeout(120000)
70
71 await MockSmtpServer.Instance.collectEmails(emails)
72
73 await flushTests()
74
75 const overrideConfig = {
76 smtp: {
77 hostname: 'localhost'
78 }
79 }
80 servers = await flushAndRunMultipleServers(2, overrideConfig)
81
82 // Get the access tokens
83 await setAccessTokensToServers(servers)
84
85 // Server 1 and server 2 follow each other
86 await doubleFollow(servers[0], servers[1])
87
88 await waitJobs(servers)
89
90 const user = {
91 username: 'user_1',
92 password: 'super password'
93 }
94 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password, 10 * 1000 * 1000)
95 userAccessToken = await userLogin(servers[0], user)
96
97 await updateMyNotificationSettings(servers[0].url, userAccessToken, {
98 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
99 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
100 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
101 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
102 })
103
104 {
105 const socket = getUserNotificationSocket(servers[ 0 ].url, userAccessToken)
106 socket.on('new-notification', n => userNotifications.push(n))
107 }
108 {
109 const socket = getUserNotificationSocket(servers[ 0 ].url, servers[0].accessToken)
110 socket.on('new-notification', n => adminNotifications.push(n))
111 }
112 })
113
114 describe('New video from my subscription notification', function () {
115 let baseParams: CheckerBaseParams
116
117 before(() => {
118 baseParams = {
119 server: servers[0],
120 emails,
121 socketNotifications: userNotifications,
122 token: userAccessToken
123 }
124 })
125
126 it('Should not send notifications if the user does not follow the video publisher', async function () {
127 await uploadVideoByLocalAccount(servers, 1)
128
129 const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
130 expect(notification).to.be.undefined
131
132 expect(emails).to.have.lengthOf(0)
133 expect(userNotifications).to.have.lengthOf(0)
134 })
135
136 it('Should send a new video notification if the user follows the local video publisher', async function () {
137 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
138
139 const videoNameId = 10
140 const videoName = 'local video ' + videoNameId
141
142 const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
143 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
144 })
145
146 it('Should send a new video notification from a remote account', async function () {
147 this.timeout(50000) // Server 2 has transcoding enabled
148
149 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
150
151 const videoNameId = 20
152 const videoName = 'remote video ' + videoNameId
153
154 const uuid = await uploadVideoByRemoteAccount(servers, videoNameId)
155 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
156 })
157
158 it('Should send a new video notification on a scheduled publication', async function () {
159 this.timeout(20000)
160
161 const videoNameId = 30
162 const videoName = 'local video ' + videoNameId
163
164 // In 2 seconds
165 let updateAt = new Date(new Date().getTime() + 2000)
166
167 const data = {
168 privacy: VideoPrivacy.PRIVATE,
169 scheduleUpdate: {
170 updateAt: updateAt.toISOString(),
171 privacy: VideoPrivacy.PUBLIC
172 }
173 }
174 const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
175
176 await wait(6000)
177 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
178 })
179
180 it('Should send a new video notification on a remote scheduled publication', async function () {
181 this.timeout(20000)
182
183 const videoNameId = 40
184 const videoName = 'remote video ' + videoNameId
185
186 // In 2 seconds
187 let updateAt = new Date(new Date().getTime() + 2000)
188
189 const data = {
190 privacy: VideoPrivacy.PRIVATE,
191 scheduleUpdate: {
192 updateAt: updateAt.toISOString(),
193 privacy: VideoPrivacy.PUBLIC
194 }
195 }
196 const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
197
198 await wait(6000)
199 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
200 })
201
202 it('Should not send a notification before the video is published', async function () {
203 this.timeout(20000)
204
205 const videoNameId = 50
206 const videoName = 'local video ' + videoNameId
207
208 let updateAt = new Date(new Date().getTime() + 100000)
209
210 const data = {
211 privacy: VideoPrivacy.PRIVATE,
212 scheduleUpdate: {
213 updateAt: updateAt.toISOString(),
214 privacy: VideoPrivacy.PUBLIC
215 }
216 }
217 const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
218
219 await wait(6000)
220 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
221 })
222
223 it('Should send a new video notification when a video becomes public', async function () {
224 this.timeout(10000)
225
226 const videoNameId = 60
227 const videoName = 'local video ' + videoNameId
228
229 const data = { privacy: VideoPrivacy.PRIVATE }
230 const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
231
232 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
233
234 await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
235
236 await wait(500)
237 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
238 })
239
240 it('Should send a new video notification when a remote video becomes public', async function () {
241 this.timeout(20000)
242
243 const videoNameId = 70
244 const videoName = 'remote video ' + videoNameId
245
246 const data = { privacy: VideoPrivacy.PRIVATE }
247 const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
248
249 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
250
251 await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
252
253 await waitJobs(servers)
254 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
255 })
256
257 it('Should not send a new video notification when a video becomes unlisted', async function () {
258 this.timeout(20000)
259
260 const videoNameId = 80
261 const videoName = 'local video ' + videoNameId
262
263 const data = { privacy: VideoPrivacy.PRIVATE }
264 const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
265
266 await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
267
268 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
269 })
270
271 it('Should not send a new video notification when a remote video becomes unlisted', async function () {
272 this.timeout(20000)
273
274 const videoNameId = 90
275 const videoName = 'remote video ' + videoNameId
276
277 const data = { privacy: VideoPrivacy.PRIVATE }
278 const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
279
280 await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
281
282 await waitJobs(servers)
283 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
284 })
285
286 it('Should send a new video notification after a video import', async function () {
287 this.timeout(30000)
288
289 const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken)
290 const channelId = resChannel.body.videoChannels[0].id
291 const videoName = 'local video 100'
292
293 const attributes = {
294 name: videoName,
295 channelId,
296 privacy: VideoPrivacy.PUBLIC,
297 targetUrl: getYoutubeVideoUrl()
298 }
299 const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
300 const uuid = res.body.video.uuid
301
302 await waitJobs(servers)
303
304 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
305 })
306 })
307
308 describe('Comment on my video notifications', function () {
309 let baseParams: CheckerBaseParams
310
311 before(() => {
312 baseParams = {
313 server: servers[0],
314 emails,
315 socketNotifications: userNotifications,
316 token: userAccessToken
317 }
318 })
319
320 it('Should not send a new comment notification after a comment on another video', async function () {
321 this.timeout(10000)
322
323 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
324 const uuid = resVideo.body.video.uuid
325
326 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
327 const commentId = resComment.body.comment.id
328
329 await wait(500)
330 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
331 })
332
333 it('Should not send a new comment notification if I comment my own video', async function () {
334 this.timeout(10000)
335
336 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
337 const uuid = resVideo.body.video.uuid
338
339 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, 'comment')
340 const commentId = resComment.body.comment.id
341
342 await wait(500)
343 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
344 })
345
346 it('Should send a new comment notification after a local comment on my video', async function () {
347 this.timeout(10000)
348
349 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
350 const uuid = resVideo.body.video.uuid
351
352 const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
353 const commentId = resComment.body.comment.id
354
355 await wait(500)
356 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
357 })
358
359 it('Should send a new comment notification after a remote comment on my video', async function () {
360 this.timeout(10000)
361
362 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
363 const uuid = resVideo.body.video.uuid
364
365 await waitJobs(servers)
366
367 const resComment = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
368 const commentId = resComment.body.comment.id
369
370 await waitJobs(servers)
371 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
372 })
373
374 it('Should send a new comment notification after a local reply on my video', async function () {
375 this.timeout(10000)
376
377 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
378 const uuid = resVideo.body.video.uuid
379
380 const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
381 const threadId = resThread.body.comment.id
382
383 const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'reply')
384 const commentId = resComment.body.comment.id
385
386 await wait(500)
387 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
388 })
389
390 it('Should send a new comment notification after a remote reply on my video', async function () {
391 this.timeout(10000)
392
393 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
394 const uuid = resVideo.body.video.uuid
395 await waitJobs(servers)
396
397 const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
398 const threadId = resThread.body.comment.id
399
400 const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, 'reply')
401 const commentId = resComment.body.comment.id
402
403 await waitJobs(servers)
404 await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
405 })
406 })
407
408 describe('Video abuse for moderators notification' , function () {
409 let baseParams: CheckerBaseParams
410
411 before(() => {
412 baseParams = {
413 server: servers[0],
414 emails,
415 socketNotifications: adminNotifications,
416 token: servers[0].accessToken
417 }
418 })
419
420 it('Should send a notification to moderators on local video abuse', async function () {
421 this.timeout(10000)
422
423 const videoName = 'local video 110'
424
425 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
426 const uuid = resVideo.body.video.uuid
427
428 await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason')
429
430 await waitJobs(servers)
431 await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
432 })
433
434 it('Should send a notification to moderators on remote video abuse', async function () {
435 this.timeout(10000)
436
437 const videoName = 'remote video 120'
438
439 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
440 const uuid = resVideo.body.video.uuid
441
442 await waitJobs(servers)
443
444 await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason')
445
446 await waitJobs(servers)
447 await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
448 })
449 })
450
451 describe('Video blacklist on my video', function () {
452 let baseParams: CheckerBaseParams
453
454 before(() => {
455 baseParams = {
456 server: servers[0],
457 emails,
458 socketNotifications: userNotifications,
459 token: userAccessToken
460 }
461 })
462
463 it('Should send a notification to video owner on blacklist', async function () {
464 this.timeout(10000)
465
466 const videoName = 'local video 130'
467
468 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
469 const uuid = resVideo.body.video.uuid
470
471 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
472
473 await waitJobs(servers)
474 await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'blacklist')
475 })
476
477 it('Should send a notification to video owner on unblacklist', async function () {
478 this.timeout(10000)
479
480 const videoName = 'local video 130'
481
482 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
483 const uuid = resVideo.body.video.uuid
484
485 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
486
487 await waitJobs(servers)
488 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, uuid)
489 await waitJobs(servers)
490
491 await wait(500)
492 await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'unblacklist')
493 })
494 })
495
496 describe('Mark as read', function () {
497 it('Should mark as read some notifications', async function () {
498 const res = await getUserNotifications(servers[0].url, userAccessToken, 2, 3)
499 const ids = res.body.data.map(n => n.id)
500
501 await markAsReadNotifications(servers[0].url, userAccessToken, ids)
502 })
503
504 it('Should have the notifications marked as read', async function () {
505 const res = await getUserNotifications(servers[0].url, userAccessToken, 0, 10)
506
507 const notifications = res.body.data as UserNotification[]
508 expect(notifications[0].read).to.be.false
509 expect(notifications[1].read).to.be.false
510 expect(notifications[2].read).to.be.true
511 expect(notifications[3].read).to.be.true
512 expect(notifications[4].read).to.be.true
513 expect(notifications[5].read).to.be.false
514 })
515 })
516
517 describe('Notification settings', function () {
518 const baseUpdateNotificationParams = {
519 newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
520 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
521 videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
522 blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
523 }
524 let baseParams: CheckerBaseParams
525
526 before(() => {
527 baseParams = {
528 server: servers[0],
529 emails,
530 socketNotifications: userNotifications,
531 token: userAccessToken
532 }
533 })
534
535 it('Should not have notifications', async function () {
536 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
537 newVideoFromSubscription: UserNotificationSettingValue.NONE
538 }))
539
540 {
541 const res = await getMyUserInformation(servers[0].url, userAccessToken)
542 const info = res.body as User
543 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE)
544 }
545
546 const videoNameId = 42
547 const videoName = 'local video ' + videoNameId
548 const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
549
550 const check = { web: true, mail: true }
551 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
552 })
553
554 it('Should only have web notifications', async function () {
555 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
556 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION
557 }))
558
559 {
560 const res = await getMyUserInformation(servers[0].url, userAccessToken)
561 const info = res.body as User
562 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION)
563 }
564
565 const videoNameId = 52
566 const videoName = 'local video ' + videoNameId
567 const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
568
569 {
570 const check = { mail: true, web: false }
571 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
572 }
573
574 {
575 const check = { mail: false, web: true }
576 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
577 }
578 })
579
580 it('Should only have mail notifications', async function () {
581 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
582 newVideoFromSubscription: UserNotificationSettingValue.EMAIL
583 }))
584
585 {
586 const res = await getMyUserInformation(servers[0].url, userAccessToken)
587 const info = res.body as User
588 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL)
589 }
590
591 const videoNameId = 62
592 const videoName = 'local video ' + videoNameId
593 const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
594
595 {
596 const check = { mail: false, web: true }
597 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
598 }
599
600 {
601 const check = { mail: true, web: false }
602 await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
603 }
604 })
605
606 it('Should have email and web notifications', async function () {
607 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
608 newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
609 }))
610
611 {
612 const res = await getMyUserInformation(servers[0].url, userAccessToken)
613 const info = res.body as User
614 expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL)
615 }
616
617 const videoNameId = 72
618 const videoName = 'local video ' + videoNameId
619 const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
620
621 await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
622 })
623 })
624
625 after(async function () {
626 killallServers(servers)
627 })
628})