aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--server.ts11
-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
-rw-r--r--shared/models/users/index.ts2
-rw-r--r--shared/models/users/user-notification-setting.model.ts13
-rw-r--r--shared/models/users/user-notification.model.ts47
-rw-r--r--shared/models/users/user.model.ts2
-rw-r--r--shared/utils/server/jobs.ts15
-rw-r--r--shared/utils/socket/socket-io.ts13
-rw-r--r--shared/utils/users/user-notifications.ts232
-rw-r--r--yarn.lock133
52 files changed, 2449 insertions, 111 deletions
diff --git a/package.json b/package.json
index 3983f5f2c..ea3f88e24 100644
--- a/package.json
+++ b/package.json
@@ -146,6 +146,7 @@
146 "sequelize-typescript": "0.6.6", 146 "sequelize-typescript": "0.6.6",
147 "sharp": "^0.21.0", 147 "sharp": "^0.21.0",
148 "sitemap": "^2.1.0", 148 "sitemap": "^2.1.0",
149 "socket.io": "^2.2.0",
149 "srt-to-vtt": "^1.1.2", 150 "srt-to-vtt": "^1.1.2",
150 "summon-install": "^0.4.3", 151 "summon-install": "^0.4.3",
151 "useragent": "^2.3.0", 152 "useragent": "^2.3.0",
@@ -189,6 +190,7 @@
189 "@types/redis": "^2.8.5", 190 "@types/redis": "^2.8.5",
190 "@types/request": "^2.0.3", 191 "@types/request": "^2.0.3",
191 "@types/sharp": "^0.21.0", 192 "@types/sharp": "^0.21.0",
193 "@types/socket.io": "^2.1.2",
192 "@types/supertest": "^2.0.3", 194 "@types/supertest": "^2.0.3",
193 "@types/validator": "^9.4.0", 195 "@types/validator": "^9.4.0",
194 "@types/webtorrent": "^0.98.4", 196 "@types/webtorrent": "^0.98.4",
diff --git a/server.ts b/server.ts
index 868a03ba4..b50151859 100644
--- a/server.ts
+++ b/server.ts
@@ -28,7 +28,7 @@ import { checkMissedConfig, checkFFmpeg } from './server/initializers/checker-be
28 28
29// Do not use barrels because we don't want to load all modules here (we need to initialize database first) 29// Do not use barrels because we don't want to load all modules here (we need to initialize database first)
30import { logger } from './server/helpers/logger' 30import { logger } from './server/helpers/logger'
31import { API_VERSION, CONFIG, CACHE, HTTP_SIGNATURE } from './server/initializers/constants' 31import { API_VERSION, CONFIG, CACHE } from './server/initializers/constants'
32 32
33const missed = checkMissedConfig() 33const missed = checkMissedConfig()
34if (missed.length !== 0) { 34if (missed.length !== 0) {
@@ -90,7 +90,7 @@ import {
90 servicesRouter, 90 servicesRouter,
91 webfingerRouter, 91 webfingerRouter,
92 trackerRouter, 92 trackerRouter,
93 createWebsocketServer, botsRouter 93 createWebsocketTrackerServer, botsRouter
94} from './server/controllers' 94} from './server/controllers'
95import { advertiseDoNotTrack } from './server/middlewares/dnt' 95import { advertiseDoNotTrack } from './server/middlewares/dnt'
96import { Redis } from './server/lib/redis' 96import { Redis } from './server/lib/redis'
@@ -100,6 +100,7 @@ import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-sch
100import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler' 100import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler'
101import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler' 101import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler'
102import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto' 102import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
103import { PeerTubeSocket } from './server/lib/peertube-socket'
103 104
104// ----------- Command line ----------- 105// ----------- Command line -----------
105 106
@@ -136,7 +137,7 @@ app.use(bodyParser.urlencoded({ extended: false }))
136app.use(bodyParser.json({ 137app.use(bodyParser.json({
137 type: [ 'application/json', 'application/*+json' ], 138 type: [ 'application/json', 'application/*+json' ],
138 limit: '500kb', 139 limit: '500kb',
139 verify: (req: express.Request, _, buf: Buffer, encoding: string) => { 140 verify: (req: express.Request, _, buf: Buffer) => {
140 const valid = isHTTPSignatureDigestValid(buf, req) 141 const valid = isHTTPSignatureDigestValid(buf, req)
141 if (valid !== true) throw new Error('Invalid digest') 142 if (valid !== true) throw new Error('Invalid digest')
142 } 143 }
@@ -189,7 +190,7 @@ app.use(function (err, req, res, next) {
189 return res.status(err.status || 500).end() 190 return res.status(err.status || 500).end()
190}) 191})
191 192
192const server = createWebsocketServer(app) 193const server = createWebsocketTrackerServer(app)
193 194
194// ----------- Run ----------- 195// ----------- Run -----------
195 196
@@ -228,6 +229,8 @@ async function startApplication () {
228 // Redis initialization 229 // Redis initialization
229 Redis.Instance.init() 230 Redis.Instance.init()
230 231
232 PeerTubeSocket.Instance.init(server)
233
231 // Make server listening 234 // Make server listening
232 server.listen(port, hostname, () => { 235 server.listen(port, hostname, () => {
233 logger.info('Server listening on %s:%d', hostname, port) 236 logger.info('Server listening on %s:%d', hostname, port)
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})
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts
index 7114741e0..cd07cf320 100644
--- a/shared/models/users/index.ts
+++ b/shared/models/users/index.ts
@@ -1,6 +1,8 @@
1export * from './user.model' 1export * from './user.model'
2export * from './user-create.model' 2export * from './user-create.model'
3export * from './user-login.model' 3export * from './user-login.model'
4export * from './user-notification.model'
5export * from './user-notification-setting.model'
4export * from './user-refresh-token.model' 6export * from './user-refresh-token.model'
5export * from './user-update.model' 7export * from './user-update.model'
6export * from './user-update-me.model' 8export * from './user-update-me.model'
diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts
new file mode 100644
index 000000000..7cecd70a2
--- /dev/null
+++ b/shared/models/users/user-notification-setting.model.ts
@@ -0,0 +1,13 @@
1export enum UserNotificationSettingValue {
2 NONE = 1,
3 WEB_NOTIFICATION = 2,
4 EMAIL = 3,
5 WEB_NOTIFICATION_AND_EMAIL = 4
6}
7
8export interface UserNotificationSetting {
9 newVideoFromSubscription: UserNotificationSettingValue
10 newCommentOnMyVideo: UserNotificationSettingValue
11 videoAbuseAsModerator: UserNotificationSettingValue
12 blacklistOnMyVideo: UserNotificationSettingValue
13}
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
new file mode 100644
index 000000000..39beb2350
--- /dev/null
+++ b/shared/models/users/user-notification.model.ts
@@ -0,0 +1,47 @@
1export enum UserNotificationType {
2 NEW_VIDEO_FROM_SUBSCRIPTION = 1,
3 NEW_COMMENT_ON_MY_VIDEO = 2,
4 NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
5 BLACKLIST_ON_MY_VIDEO = 4,
6 UNBLACKLIST_ON_MY_VIDEO = 5
7}
8
9interface VideoInfo {
10 id: number
11 uuid: string
12 name: string
13}
14
15export interface UserNotification {
16 id: number
17 type: UserNotificationType
18 read: boolean
19
20 video?: VideoInfo & {
21 channel: {
22 id: number
23 displayName: string
24 }
25 }
26
27 comment?: {
28 id: number
29 account: {
30 id: number
31 displayName: string
32 }
33 }
34
35 videoAbuse?: {
36 id: number
37 video: VideoInfo
38 }
39
40 videoBlacklist?: {
41 id: number
42 video: VideoInfo
43 }
44
45 createdAt: string
46 updatedAt: string
47}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 2aabff494..af783d389 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -2,6 +2,7 @@ import { Account } from '../actors'
2import { VideoChannel } from '../videos/channel/video-channel.model' 2import { VideoChannel } from '../videos/channel/video-channel.model'
3import { UserRole } from './user-role' 3import { UserRole } from './user-role'
4import { NSFWPolicyType } from '../videos/nsfw-policy.type' 4import { NSFWPolicyType } from '../videos/nsfw-policy.type'
5import { UserNotificationSetting } from './user-notification-setting.model'
5 6
6export interface User { 7export interface User {
7 id: number 8 id: number
@@ -19,6 +20,7 @@ export interface User {
19 videoQuotaDaily: number 20 videoQuotaDaily: number
20 createdAt: Date 21 createdAt: Date
21 account: Account 22 account: Account
23 notificationSettings?: UserNotificationSetting
22 videoChannels?: VideoChannel[] 24 videoChannels?: VideoChannel[]
23 25
24 blocked: boolean 26 blocked: boolean
diff --git a/shared/utils/server/jobs.ts b/shared/utils/server/jobs.ts
index f4623f896..6218c0b66 100644
--- a/shared/utils/server/jobs.ts
+++ b/shared/utils/server/jobs.ts
@@ -35,10 +35,10 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
35 else servers = serversArg as ServerInfo[] 35 else servers = serversArg as ServerInfo[]
36 36
37 const states: JobState[] = [ 'waiting', 'active', 'delayed' ] 37 const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
38 const tasks: Promise<any>[] = [] 38 let pendingRequests = false
39 let pendingRequests: boolean
40 39
41 do { 40 function tasksBuilder () {
41 const tasks: Promise<any>[] = []
42 pendingRequests = false 42 pendingRequests = false
43 43
44 // Check if each server has pending request 44 // Check if each server has pending request
@@ -54,13 +54,16 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
54 } 54 }
55 } 55 }
56 56
57 await Promise.all(tasks) 57 return tasks
58 }
59
60 do {
61 await Promise.all(tasksBuilder())
58 62
59 // Retry, in case of new jobs were created 63 // Retry, in case of new jobs were created
60 if (pendingRequests === false) { 64 if (pendingRequests === false) {
61 await wait(2000) 65 await wait(2000)
62 66 await Promise.all(tasksBuilder())
63 await Promise.all(tasks)
64 } 67 }
65 68
66 if (pendingRequests) { 69 if (pendingRequests) {
diff --git a/shared/utils/socket/socket-io.ts b/shared/utils/socket/socket-io.ts
new file mode 100644
index 000000000..854ab71af
--- /dev/null
+++ b/shared/utils/socket/socket-io.ts
@@ -0,0 +1,13 @@
1import * as io from 'socket.io-client'
2
3function getUserNotificationSocket (serverUrl: string, accessToken: string) {
4 return io(serverUrl + '/user-notifications', {
5 query: { accessToken }
6 })
7}
8
9// ---------------------------------------------------------------------------
10
11export {
12 getUserNotificationSocket
13}
diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts
new file mode 100644
index 000000000..dbe87559e
--- /dev/null
+++ b/shared/utils/users/user-notifications.ts
@@ -0,0 +1,232 @@
1/* tslint:disable:no-unused-expression */
2
3import { makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
4import { UserNotification, UserNotificationSetting, UserNotificationType } from '../../models/users'
5import { ServerInfo } from '..'
6import { expect } from 'chai'
7
8function updateMyNotificationSettings (url: string, token: string, settings: UserNotificationSetting, statusCodeExpected = 204) {
9 const path = '/api/v1/users/me/notification-settings'
10
11 return makePutBodyRequest({
12 url,
13 path,
14 token,
15 fields: settings,
16 statusCodeExpected
17 })
18}
19
20function getUserNotifications (url: string, token: string, start: number, count: number, sort = '-createdAt', statusCodeExpected = 200) {
21 const path = '/api/v1/users/me/notifications'
22
23 return makeGetRequest({
24 url,
25 path,
26 token,
27 query: {
28 start,
29 count,
30 sort
31 },
32 statusCodeExpected
33 })
34}
35
36function markAsReadNotifications (url: string, token: string, ids: number[], statusCodeExpected = 204) {
37 const path = '/api/v1/users/me/notifications/read'
38
39 return makePostBodyRequest({
40 url,
41 path,
42 token,
43 fields: { ids },
44 statusCodeExpected
45 })
46}
47
48async function getLastNotification (serverUrl: string, accessToken: string) {
49 const res = await getUserNotifications(serverUrl, accessToken, 0, 1, '-createdAt')
50
51 if (res.body.total === 0) return undefined
52
53 return res.body.data[0] as UserNotification
54}
55
56type CheckerBaseParams = {
57 server: ServerInfo
58 emails: object[]
59 socketNotifications: UserNotification[]
60 token: string,
61 check?: { web: boolean, mail: boolean }
62}
63
64type CheckerType = 'presence' | 'absence'
65
66async function checkNotification (
67 base: CheckerBaseParams,
68 lastNotificationChecker: (notification: UserNotification) => void,
69 socketNotificationFinder: (notification: UserNotification) => boolean,
70 emailNotificationFinder: (email: object) => boolean,
71 checkType: 'presence' | 'absence'
72) {
73 const check = base.check || { web: true, mail: true }
74
75 if (check.web) {
76 const notification = await getLastNotification(base.server.url, base.token)
77 lastNotificationChecker(notification)
78
79 const socketNotification = base.socketNotifications.find(n => socketNotificationFinder(n))
80
81 if (checkType === 'presence') expect(socketNotification, 'The socket notification is absent.').to.not.be.undefined
82 else expect(socketNotification, 'The socket notification is present.').to.be.undefined
83 }
84
85 if (check.mail) {
86 // Last email
87 const email = base.emails
88 .slice()
89 .reverse()
90 .find(e => emailNotificationFinder(e))
91
92 if (checkType === 'presence') expect(email, 'The email is present.').to.not.be.undefined
93 else expect(email, 'The email is absent.').to.be.undefined
94 }
95}
96
97async function checkNewVideoFromSubscription (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) {
98 const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
99
100 function lastNotificationChecker (notification: UserNotification) {
101 if (type === 'presence') {
102 expect(notification).to.not.be.undefined
103 expect(notification.type).to.equal(notificationType)
104 expect(notification.video.name).to.equal(videoName)
105 } else {
106 expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
107 }
108 }
109
110 function socketFinder (notification: UserNotification) {
111 return notification.type === notificationType && notification.video.name === videoName
112 }
113
114 function emailFinder (email: object) {
115 return email[ 'text' ].indexOf(videoUUID) !== -1
116 }
117
118 await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
119}
120
121let lastEmailCount = 0
122async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) {
123 const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
124
125 function lastNotificationChecker (notification: UserNotification) {
126 if (type === 'presence') {
127 expect(notification).to.not.be.undefined
128 expect(notification.type).to.equal(notificationType)
129 expect(notification.comment.id).to.equal(commentId)
130 expect(notification.comment.account.displayName).to.equal('root')
131 } else {
132 expect(notification).to.satisfy((n: UserNotification) => {
133 return n === undefined || n.comment === undefined || n.comment.id !== commentId
134 })
135 }
136 }
137
138 function socketFinder (notification: UserNotification) {
139 return notification.type === notificationType &&
140 notification.comment.id === commentId &&
141 notification.comment.account.displayName === 'root'
142 }
143
144 const commentUrl = `http://localhost:9001/videos/watch/${uuid};threadId=${threadId}`
145 function emailFinder (email: object) {
146 return email[ 'text' ].indexOf(commentUrl) !== -1
147 }
148
149 await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
150
151 if (type === 'presence') {
152 // We cannot detect email duplicates, so check we received another email
153 expect(base.emails).to.have.length.above(lastEmailCount)
154 lastEmailCount = base.emails.length
155 }
156}
157
158async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
159 const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS
160
161 function lastNotificationChecker (notification: UserNotification) {
162 if (type === 'presence') {
163 expect(notification).to.not.be.undefined
164 expect(notification.type).to.equal(notificationType)
165 expect(notification.videoAbuse.video.uuid).to.equal(videoUUID)
166 expect(notification.videoAbuse.video.name).to.equal(videoName)
167 } else {
168 expect(notification).to.satisfy((n: UserNotification) => {
169 return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID
170 })
171 }
172 }
173
174 function socketFinder (notification: UserNotification) {
175 return notification.type === notificationType && notification.videoAbuse.video.uuid === videoUUID
176 }
177
178 function emailFinder (email: object) {
179 const text = email[ 'text' ]
180 return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
181 }
182
183 await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
184}
185
186async function checkNewBlacklistOnMyVideo (
187 base: CheckerBaseParams,
188 videoUUID: string,
189 videoName: string,
190 blacklistType: 'blacklist' | 'unblacklist'
191) {
192 const notificationType = blacklistType === 'blacklist'
193 ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
194 : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO
195
196 function lastNotificationChecker (notification: UserNotification) {
197 expect(notification).to.not.be.undefined
198 expect(notification.type).to.equal(notificationType)
199
200 const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video
201
202 expect(video.uuid).to.equal(videoUUID)
203 expect(video.name).to.equal(videoName)
204 }
205
206 function socketFinder (notification: UserNotification) {
207 return notification.type === notificationType && (notification.video || notification.videoBlacklist.video).uuid === videoUUID
208 }
209
210 function emailFinder (email: object) {
211 const text = email[ 'text' ]
212 return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1
213 }
214
215 await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, 'presence')
216}
217
218// ---------------------------------------------------------------------------
219
220export {
221 CheckerBaseParams,
222 CheckerType,
223 checkNotification,
224 checkNewVideoFromSubscription,
225 checkNewCommentOnMyVideo,
226 checkNewBlacklistOnMyVideo,
227 updateMyNotificationSettings,
228 checkNewVideoAbuseForModerators,
229 getUserNotifications,
230 markAsReadNotifications,
231 getLastNotification
232}
diff --git a/yarn.lock b/yarn.lock
index 6eb6c9a59..1e759af1b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -346,6 +346,13 @@
346 dependencies: 346 dependencies:
347 "@types/node" "*" 347 "@types/node" "*"
348 348
349"@types/socket.io@^2.1.2":
350 version "2.1.2"
351 resolved "https://registry.yarnpkg.com/@types/socket.io/-/socket.io-2.1.2.tgz#7165c2587cc3b86b44aa78e2a0060140551de211"
352 integrity sha512-Ind+4qMNfQ62llyB4IMs1D8znMEBsMKohZBPqfBUIXqLQ9bdtWIbNTBWwtdcBWJKnokMZGcmWOOKslatni5vtA==
353 dependencies:
354 "@types/node" "*"
355
349"@types/superagent@*": 356"@types/superagent@*":
350 version "3.8.4" 357 version "3.8.4"
351 resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.4.tgz#24a5973c7d1a9c024b4bbda742a79267c33fb86a" 358 resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.4.tgz#24a5973c7d1a9c024b4bbda742a79267c33fb86a"
@@ -423,7 +430,7 @@ accepts@~1.2.12:
423 mime-types "~2.1.6" 430 mime-types "~2.1.6"
424 negotiator "0.5.3" 431 negotiator "0.5.3"
425 432
426accepts@~1.3.5: 433accepts@~1.3.4, accepts@~1.3.5:
427 version "1.3.5" 434 version "1.3.5"
428 resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" 435 resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
429 integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I= 436 integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I=
@@ -652,6 +659,11 @@ arraybuffer.slice@0.0.6:
652 resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca" 659 resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca"
653 integrity sha1-8zshWfBTKj8xB6JywMz70a0peco= 660 integrity sha1-8zshWfBTKj8xB6JywMz70a0peco=
654 661
662arraybuffer.slice@~0.0.7:
663 version "0.0.7"
664 resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
665 integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
666
655arrify@^1.0.0, arrify@^1.0.1: 667arrify@^1.0.0, arrify@^1.0.1:
656 version "1.0.1" 668 version "1.0.1"
657 resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" 669 resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
@@ -977,6 +989,11 @@ blob@0.0.4:
977 resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" 989 resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921"
978 integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE= 990 integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=
979 991
992blob@0.0.5:
993 version "0.0.5"
994 resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
995 integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
996
980block-stream2@^1.0.0: 997block-stream2@^1.0.0:
981 version "1.1.0" 998 version "1.1.0"
982 resolved "https://registry.yarnpkg.com/block-stream2/-/block-stream2-1.1.0.tgz#c738e3a91ba977ebb5e1fef431e13ca11d8639e2" 999 resolved "https://registry.yarnpkg.com/block-stream2/-/block-stream2-1.1.0.tgz#c738e3a91ba977ebb5e1fef431e13ca11d8639e2"
@@ -1995,7 +2012,7 @@ debug@2.6.9, debug@^2.1.1, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.
1995 dependencies: 2012 dependencies:
1996 ms "2.0.0" 2013 ms "2.0.0"
1997 2014
1998debug@3.1.0: 2015debug@3.1.0, debug@~3.1.0:
1999 version "3.1.0" 2016 version "3.1.0"
2000 resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" 2017 resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
2001 integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== 2018 integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
@@ -2016,6 +2033,13 @@ debug@^4.0.1:
2016 dependencies: 2033 dependencies:
2017 ms "^2.1.1" 2034 ms "^2.1.1"
2018 2035
2036debug@~4.1.0:
2037 version "4.1.1"
2038 resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
2039 integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
2040 dependencies:
2041 ms "^2.1.1"
2042
2019debuglog@^1.0.0, debuglog@^1.0.1: 2043debuglog@^1.0.0, debuglog@^1.0.1:
2020 version "1.0.1" 2044 version "1.0.1"
2021 resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" 2045 resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
@@ -2367,6 +2391,23 @@ engine.io-client@1.8.3:
2367 xmlhttprequest-ssl "1.5.3" 2391 xmlhttprequest-ssl "1.5.3"
2368 yeast "0.1.2" 2392 yeast "0.1.2"
2369 2393
2394engine.io-client@~3.3.1:
2395 version "3.3.1"
2396 resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03"
2397 integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ==
2398 dependencies:
2399 component-emitter "1.2.1"
2400 component-inherit "0.0.3"
2401 debug "~3.1.0"
2402 engine.io-parser "~2.1.1"
2403 has-cors "1.1.0"
2404 indexof "0.0.1"
2405 parseqs "0.0.5"
2406 parseuri "0.0.5"
2407 ws "~6.1.0"
2408 xmlhttprequest-ssl "~1.5.4"
2409 yeast "0.1.2"
2410
2370engine.io-parser@1.3.2: 2411engine.io-parser@1.3.2:
2371 version "1.3.2" 2412 version "1.3.2"
2372 resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a" 2413 resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a"
@@ -2379,6 +2420,17 @@ engine.io-parser@1.3.2:
2379 has-binary "0.1.7" 2420 has-binary "0.1.7"
2380 wtf-8 "1.0.0" 2421 wtf-8 "1.0.0"
2381 2422
2423engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
2424 version "2.1.3"
2425 resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
2426 integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
2427 dependencies:
2428 after "0.8.2"
2429 arraybuffer.slice "~0.0.7"
2430 base64-arraybuffer "0.1.5"
2431 blob "0.0.5"
2432 has-binary2 "~1.0.2"
2433
2382engine.io@1.8.3: 2434engine.io@1.8.3:
2383 version "1.8.3" 2435 version "1.8.3"
2384 resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4" 2436 resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4"
@@ -2391,6 +2443,18 @@ engine.io@1.8.3:
2391 engine.io-parser "1.3.2" 2443 engine.io-parser "1.3.2"
2392 ws "1.1.2" 2444 ws "1.1.2"
2393 2445
2446engine.io@~3.3.1:
2447 version "3.3.2"
2448 resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
2449 integrity sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==
2450 dependencies:
2451 accepts "~1.3.4"
2452 base64id "1.0.0"
2453 cookie "0.3.1"
2454 debug "~3.1.0"
2455 engine.io-parser "~2.1.0"
2456 ws "~6.1.0"
2457
2394env-variable@0.0.x: 2458env-variable@0.0.x:
2395 version "0.0.5" 2459 version "0.0.5"
2396 resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88" 2460 resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
@@ -3389,6 +3453,13 @@ has-ansi@^2.0.0:
3389 dependencies: 3453 dependencies:
3390 ansi-regex "^2.0.0" 3454 ansi-regex "^2.0.0"
3391 3455
3456has-binary2@~1.0.2:
3457 version "1.0.3"
3458 resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
3459 integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
3460 dependencies:
3461 isarray "2.0.1"
3462
3392has-binary@0.1.7: 3463has-binary@0.1.7:
3393 version "0.1.7" 3464 version "0.1.7"
3394 resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c" 3465 resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c"
@@ -4131,6 +4202,11 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
4131 resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" 4202 resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
4132 integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= 4203 integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
4133 4204
4205isarray@2.0.1:
4206 version "2.0.1"
4207 resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
4208 integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
4209
4134isexe@^2.0.0: 4210isexe@^2.0.0:
4135 version "2.0.0" 4211 version "2.0.0"
4136 resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 4212 resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -7542,6 +7618,11 @@ socket.io-adapter@0.5.0:
7542 debug "2.3.3" 7618 debug "2.3.3"
7543 socket.io-parser "2.3.1" 7619 socket.io-parser "2.3.1"
7544 7620
7621socket.io-adapter@~1.1.0:
7622 version "1.1.1"
7623 resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
7624 integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=
7625
7545socket.io-client@1.7.3: 7626socket.io-client@1.7.3:
7546 version "1.7.3" 7627 version "1.7.3"
7547 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.3.tgz#b30e86aa10d5ef3546601c09cde4765e381da377" 7628 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.3.tgz#b30e86aa10d5ef3546601c09cde4765e381da377"
@@ -7559,6 +7640,26 @@ socket.io-client@1.7.3:
7559 socket.io-parser "2.3.1" 7640 socket.io-parser "2.3.1"
7560 to-array "0.1.4" 7641 to-array "0.1.4"
7561 7642
7643socket.io-client@2.2.0:
7644 version "2.2.0"
7645 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
7646 integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
7647 dependencies:
7648 backo2 "1.0.2"
7649 base64-arraybuffer "0.1.5"
7650 component-bind "1.0.0"
7651 component-emitter "1.2.1"
7652 debug "~3.1.0"
7653 engine.io-client "~3.3.1"
7654 has-binary2 "~1.0.2"
7655 has-cors "1.1.0"
7656 indexof "0.0.1"
7657 object-component "0.0.3"
7658 parseqs "0.0.5"
7659 parseuri "0.0.5"
7660 socket.io-parser "~3.3.0"
7661 to-array "0.1.4"
7662
7562socket.io-parser@2.3.1: 7663socket.io-parser@2.3.1:
7563 version "2.3.1" 7664 version "2.3.1"
7564 resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0" 7665 resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0"
@@ -7569,6 +7670,15 @@ socket.io-parser@2.3.1:
7569 isarray "0.0.1" 7670 isarray "0.0.1"
7570 json3 "3.3.2" 7671 json3 "3.3.2"
7571 7672
7673socket.io-parser@~3.3.0:
7674 version "3.3.0"
7675 resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
7676 integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
7677 dependencies:
7678 component-emitter "1.2.1"
7679 debug "~3.1.0"
7680 isarray "2.0.1"
7681
7572socket.io@1.7.3: 7682socket.io@1.7.3:
7573 version "1.7.3" 7683 version "1.7.3"
7574 resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b" 7684 resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b"
@@ -7582,6 +7692,18 @@ socket.io@1.7.3:
7582 socket.io-client "1.7.3" 7692 socket.io-client "1.7.3"
7583 socket.io-parser "2.3.1" 7693 socket.io-parser "2.3.1"
7584 7694
7695socket.io@^2.2.0:
7696 version "2.2.0"
7697 resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
7698 integrity sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==
7699 dependencies:
7700 debug "~4.1.0"
7701 engine.io "~3.3.1"
7702 has-binary2 "~1.0.2"
7703 socket.io-adapter "~1.1.0"
7704 socket.io-client "2.2.0"
7705 socket.io-parser "~3.3.0"
7706
7585socks-proxy-agent@^3.0.1: 7707socks-proxy-agent@^3.0.1:
7586 version "3.0.1" 7708 version "3.0.1"
7587 resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz#2eae7cf8e2a82d34565761539a7f9718c5617659" 7709 resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz#2eae7cf8e2a82d34565761539a7f9718c5617659"
@@ -8954,7 +9076,7 @@ ws@1.1.2:
8954 options ">=0.0.5" 9076 options ">=0.0.5"
8955 ultron "1.0.x" 9077 ultron "1.0.x"
8956 9078
8957ws@^6.0.0: 9079ws@^6.0.0, ws@~6.1.0:
8958 version "6.1.2" 9080 version "6.1.2"
8959 resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8" 9081 resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
8960 integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw== 9082 integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==
@@ -9028,6 +9150,11 @@ xmlhttprequest-ssl@1.5.3:
9028 resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d" 9150 resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"
9029 integrity sha1-GFqIjATspGw+QHDZn3tJ3jUomS0= 9151 integrity sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=
9030 9152
9153xmlhttprequest-ssl@~1.5.4:
9154 version "1.5.5"
9155 resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
9156 integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
9157
9031"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: 9158"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
9032 version "4.0.1" 9159 version "4.0.1"
9033 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" 9160 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"