Add user notification base code
authorChocobozzz <me@florianbigard.com>
Wed, 26 Dec 2018 09:36:24 +0000 (10:36 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 9 Jan 2019 10:15:15 +0000 (11:15 +0100)
52 files changed:
package.json
server.ts
server/controllers/api/users/index.ts
server/controllers/api/users/my-notifications.ts [new file with mode: 0644]
server/controllers/api/videos/abuse.ts
server/controllers/api/videos/blacklist.ts
server/controllers/api/videos/comment.ts
server/controllers/api/videos/index.ts
server/controllers/feeds.ts
server/controllers/tracker.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/user-notifications.ts [new file with mode: 0644]
server/initializers/constants.ts
server/initializers/database.ts
server/lib/activitypub/process/process-announce.ts
server/lib/activitypub/process/process-create.ts
server/lib/activitypub/video-comments.ts
server/lib/activitypub/videos.ts
server/lib/client-html.ts
server/lib/emailer.ts
server/lib/job-queue/handlers/video-file.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/notifier.ts [new file with mode: 0644]
server/lib/oauth-model.ts
server/lib/peertube-socket.ts [new file with mode: 0644]
server/lib/schedulers/update-videos-scheduler.ts
server/lib/user.ts
server/middlewares/oauth.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/user-history.ts
server/middlewares/validators/user-notifications.ts [new file with mode: 0644]
server/models/account/user-notification-setting.ts [new file with mode: 0644]
server/models/account/user-notification.ts [new file with mode: 0644]
server/models/account/user.ts
server/models/activitypub/actor-follow.ts
server/models/activitypub/actor.ts
server/models/video/video-abuse.ts
server/models/video/video-blacklist.ts
server/models/video/video-comment.ts
server/models/video/video.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/user-notifications.ts [new file with mode: 0644]
server/tests/api/users/index.ts
server/tests/api/users/user-notifications.ts [new file with mode: 0644]
shared/models/users/index.ts
shared/models/users/user-notification-setting.model.ts [new file with mode: 0644]
shared/models/users/user-notification.model.ts [new file with mode: 0644]
shared/models/users/user.model.ts
shared/utils/server/jobs.ts
shared/utils/socket/socket-io.ts [new file with mode: 0644]
shared/utils/users/user-notifications.ts [new file with mode: 0644]
yarn.lock

index 3983f5f2ce5e77bed50099ddc6caf48a67730453..ea3f88e24e510e1e32e2c76c369f887d1f87cf8b 100644 (file)
     "sequelize-typescript": "0.6.6",
     "sharp": "^0.21.0",
     "sitemap": "^2.1.0",
+    "socket.io": "^2.2.0",
     "srt-to-vtt": "^1.1.2",
     "summon-install": "^0.4.3",
     "useragent": "^2.3.0",
     "@types/redis": "^2.8.5",
     "@types/request": "^2.0.3",
     "@types/sharp": "^0.21.0",
+    "@types/socket.io": "^2.1.2",
     "@types/supertest": "^2.0.3",
     "@types/validator": "^9.4.0",
     "@types/webtorrent": "^0.98.4",
index 868a03ba49b6c2a9667003f465b1e8cb4a03e7e3..b501518595ac9d790297c488f28fcfe3d42a74cb 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -28,7 +28,7 @@ import { checkMissedConfig, checkFFmpeg } from './server/initializers/checker-be
 
 // Do not use barrels because we don't want to load all modules here (we need to initialize database first)
 import { logger } from './server/helpers/logger'
-import { API_VERSION, CONFIG, CACHE, HTTP_SIGNATURE } from './server/initializers/constants'
+import { API_VERSION, CONFIG, CACHE } from './server/initializers/constants'
 
 const missed = checkMissedConfig()
 if (missed.length !== 0) {
@@ -90,7 +90,7 @@ import {
   servicesRouter,
   webfingerRouter,
   trackerRouter,
-  createWebsocketServer, botsRouter
+  createWebsocketTrackerServer, botsRouter
 } from './server/controllers'
 import { advertiseDoNotTrack } from './server/middlewares/dnt'
 import { Redis } from './server/lib/redis'
@@ -100,6 +100,7 @@ import { UpdateVideosScheduler } from './server/lib/schedulers/update-videos-sch
 import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler'
 import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler'
 import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
+import { PeerTubeSocket } from './server/lib/peertube-socket'
 
 // ----------- Command line -----------
 
@@ -136,7 +137,7 @@ app.use(bodyParser.urlencoded({ extended: false }))
 app.use(bodyParser.json({
   type: [ 'application/json', 'application/*+json' ],
   limit: '500kb',
-  verify: (req: express.Request, _, buf: Buffer, encoding: string) => {
+  verify: (req: express.Request, _, buf: Buffer) => {
     const valid = isHTTPSignatureDigestValid(buf, req)
     if (valid !== true) throw new Error('Invalid digest')
   }
@@ -189,7 +190,7 @@ app.use(function (err, req, res, next) {
   return res.status(err.status || 500).end()
 })
 
-const server = createWebsocketServer(app)
+const server = createWebsocketTrackerServer(app)
 
 // ----------- Run -----------
 
@@ -228,6 +229,8 @@ async function startApplication () {
   // Redis initialization
   Redis.Instance.init()
 
+  PeerTubeSocket.Instance.init(server)
+
   // Make server listening
   server.listen(port, hostname, () => {
     logger.info('Server listening on %s:%d', hostname, port)
index bc24792a2da1df78567802c9c3b62ad612f529e7..98be46ea2fedbc64e1df983408760613f9022ce5 100644 (file)
@@ -39,6 +39,7 @@ import { meRouter } from './me'
 import { deleteUserToken } from '../../../lib/oauth-model'
 import { myBlocklistRouter } from './my-blocklist'
 import { myVideosHistoryRouter } from './my-history'
+import { myNotificationsRouter } from './my-notifications'
 
 const auditLogger = auditLoggerFactory('users')
 
@@ -55,6 +56,7 @@ const askSendEmailLimiter = new RateLimit({
 })
 
 const usersRouter = express.Router()
+usersRouter.use('/', myNotificationsRouter)
 usersRouter.use('/', myBlocklistRouter)
 usersRouter.use('/', myVideosHistoryRouter)
 usersRouter.use('/', meRouter)
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
new file mode 100644 (file)
index 0000000..cef1d23
--- /dev/null
@@ -0,0 +1,84 @@
+import * as express from 'express'
+import 'multer'
+import {
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  paginationValidator,
+  setDefaultPagination,
+  setDefaultSort,
+  userNotificationsSortValidator
+} from '../../../middlewares'
+import { UserModel } from '../../../models/account/user'
+import { getFormattedObjects } from '../../../helpers/utils'
+import { UserNotificationModel } from '../../../models/account/user-notification'
+import { meRouter } from './me'
+import {
+  markAsReadUserNotificationsValidator,
+  updateNotificationSettingsValidator
+} from '../../../middlewares/validators/user-notifications'
+import { UserNotificationSetting } from '../../../../shared/models/users'
+import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
+
+const myNotificationsRouter = express.Router()
+
+meRouter.put('/me/notification-settings',
+  authenticate,
+  updateNotificationSettingsValidator,
+  asyncRetryTransactionMiddleware(updateNotificationSettings)
+)
+
+myNotificationsRouter.get('/me/notifications',
+  authenticate,
+  paginationValidator,
+  userNotificationsSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  asyncMiddleware(listUserNotifications)
+)
+
+myNotificationsRouter.post('/me/notifications/read',
+  authenticate,
+  markAsReadUserNotificationsValidator,
+  asyncMiddleware(markAsReadUserNotifications)
+)
+
+export {
+  myNotificationsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function updateNotificationSettings (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+  const body: UserNotificationSetting = req.body
+
+  const query = {
+    where: {
+      userId: user.id
+    }
+  }
+
+  await UserNotificationSettingModel.update({
+    newVideoFromSubscription: body.newVideoFromSubscription,
+    newCommentOnMyVideo: body.newCommentOnMyVideo
+  }, query)
+
+  return res.status(204).end()
+}
+
+async function listUserNotifications (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+
+  const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort)
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
+  const user: UserModel = res.locals.oauth.token.User
+
+  await UserNotificationModel.markAsRead(user.id, req.body.ids)
+
+  return res.status(204).end()
+}
index d0c81804bfe538036938e69bb4432880704554da..fe0a95cd51780eeacfe7c268896a7a3f77a8d636 100644 (file)
@@ -22,6 +22,7 @@ import { VideoModel } from '../../../models/video/video'
 import { VideoAbuseModel } from '../../../models/video/video-abuse'
 import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
 import { UserModel } from '../../../models/account/user'
+import { Notifier } from '../../../lib/notifier'
 
 const auditLogger = auditLoggerFactory('abuse')
 const abuseVideoRouter = express.Router()
@@ -117,6 +118,8 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
       await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance)
     }
 
+    Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
+
     auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON()))
 
     return videoAbuseInstance
index 7f803c8e939a3668050b8438f7082b284e717634..9ef08812b98de48e2f265ff690f45d323e1bfcc0 100644 (file)
@@ -16,6 +16,8 @@ import {
 } from '../../../middlewares'
 import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
 import { sequelizeTypescript } from '../../../initializers'
+import { Notifier } from '../../../lib/notifier'
+import { VideoModel } from '../../../models/video/video'
 
 const blacklistRouter = express.Router()
 
@@ -67,13 +69,18 @@ async function addVideoToBlacklist (req: express.Request, res: express.Response)
     reason: body.reason
   }
 
-  await VideoBlacklistModel.create(toCreate)
+  const blacklist = await VideoBlacklistModel.create(toCreate)
+  blacklist.Video = videoInstance
+
+  Notifier.Instance.notifyOnVideoBlacklist(blacklist)
+
+  logger.info('Video %s blacklisted.', res.locals.video.uuid)
+
   return res.type('json').status(204).end()
 }
 
 async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
   const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
-  logger.info(videoBlacklist)
 
   if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
 
@@ -92,11 +99,14 @@ async function listBlacklist (req: express.Request, res: express.Response, next:
 
 async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) {
   const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
+  const video: VideoModel = res.locals.video
 
   await sequelizeTypescript.transaction(t => {
     return videoBlacklist.destroy({ transaction: t })
   })
 
+  Notifier.Instance.notifyOnVideoUnblacklist(video)
+
   logger.info('Video %s removed from blacklist.', res.locals.video.uuid)
 
   return res.type('json').status(204).end()
index 3875c8f79baf1c0e3f7041cd8bf54549e0ecca17..70c1148ba3839945a4a45a5d9556f66d5bb486b0 100644 (file)
@@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
 import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
 import { AccountModel } from '../../../models/account/account'
 import { UserModel } from '../../../models/account/user'
+import { Notifier } from '../../../lib/notifier'
 
 const auditLogger = auditLoggerFactory('comments')
 const videoCommentRouter = express.Router()
@@ -119,6 +120,7 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
     }, t)
   })
 
+  Notifier.Instance.notifyOnNewComment(comment)
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
   return res.json({
@@ -140,6 +142,7 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
     }, t)
   })
 
+  Notifier.Instance.notifyOnNewComment(comment)
   auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
   return res.json({ comment: comment.toFormattedJSON() }).end()
index 00a1302d14287a41185283029c1ebd4b19d108f2..94ed08fedb4ea2d6af1db4f5a2fd5a34029bda90 100644 (file)
@@ -7,7 +7,8 @@ import { logger } from '../../../helpers/logger'
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
 import {
-  CONFIG, MIMETYPES,
+  CONFIG,
+  MIMETYPES,
   PREVIEWS_SIZE,
   sequelizeTypescript,
   THUMBNAILS_SIZE,
@@ -57,6 +58,7 @@ import { videoImportsRouter } from './import'
 import { resetSequelizeInstance } from '../../../helpers/database-utils'
 import { move } from 'fs-extra'
 import { watchingRouter } from './watching'
+import { Notifier } from '../../../lib/notifier'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -262,6 +264,7 @@ async function addVideo (req: express.Request, res: express.Response) {
     }
 
     await federateVideoIfNeeded(video, true, t)
+    Notifier.Instance.notifyOnNewVideo(video)
 
     auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
     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) {
   const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
   const videoInfoToUpdate: VideoUpdate = req.body
   const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
+  const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
 
   // Process thumbnail or create it from the video
   if (req.files && req.files['thumbnailfile']) {
@@ -363,6 +367,10 @@ async function updateVideo (req: express.Request, res: express.Response) {
       const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
       await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
 
+      if (wasUnlistedVideo || wasPrivateVideo) {
+        Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
+      }
+
       auditLogger.update(
         getAuditIdFromRes(res),
         new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
index ccb9b60292e7a26818f602290cbb95ef24d79860..960085af1af685d017c79e71fd324959105453a6 100644 (file)
@@ -56,7 +56,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
 
   // Adding video items to the feed, one at a time
   comments.forEach(comment => {
-    const link = CONFIG.WEBSERVER.URL + '/videos/watch/' + comment.Video.uuid + ';threadId=' + comment.getThreadId()
+    const link = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
 
     feed.addItem({
       title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`,
index 9bc7586d19a23412e4652cf28228e60f0971fd08..53f1653b5bcd5fea221917feb2221ea4fa82bd74 100644 (file)
@@ -59,7 +59,7 @@ const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
 trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
 trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
 
-function createWebsocketServer (app: express.Application) {
+function createWebsocketTrackerServer (app: express.Application) {
   const server = http.createServer(app)
   const wss = new WebSocketServer({ server: server, path: '/tracker/socket' })
   wss.on('connection', function (ws, req) {
@@ -76,7 +76,7 @@ function createWebsocketServer (app: express.Application) {
 
 export {
   trackerRouter,
-  createWebsocketServer
+  createWebsocketTrackerServer
 }
 
 // ---------------------------------------------------------------------------
index 6d10a65a881ee75b9f656ebe76aed2d363f1db2e..a093e3e1b8eaac1d2bfc7675c22f02cd22e922a4 100644 (file)
@@ -9,6 +9,10 @@ function isArray (value: any) {
   return Array.isArray(value)
 }
 
+function isIntArray (value: any) {
+  return Array.isArray(value) && value.every(v => validator.isInt('' + v))
+}
+
 function isDateValid (value: string) {
   return exists(value) && validator.isISO8601(value)
 }
@@ -78,6 +82,7 @@ function isFileValid (
 
 export {
   exists,
+  isIntArray,
   isArray,
   isIdValid,
   isUUIDValid,
diff --git a/server/helpers/custom-validators/user-notifications.ts b/server/helpers/custom-validators/user-notifications.ts
new file mode 100644 (file)
index 0000000..4fb5d92
--- /dev/null
@@ -0,0 +1,19 @@
+import { exists } from './misc'
+import * as validator from 'validator'
+import { UserNotificationType } from '../../../shared/models/users'
+import { UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
+
+function isUserNotificationTypeValid (value: any) {
+  return exists(value) && validator.isInt('' + value) && UserNotificationType[value] !== undefined
+}
+
+function isUserNotificationSettingValid (value: any) {
+  return exists(value) &&
+    validator.isInt('' + value) &&
+    UserNotificationSettingValue[ value ] !== undefined
+}
+
+export {
+  isUserNotificationSettingValid,
+  isUserNotificationTypeValid
+}
index c3df2383adeea17e7f29a60a0b8fdb3b5ae602e6..fcfaf71a01e2ae8ee28555b1cb853726065e3aac 100644 (file)
@@ -50,7 +50,9 @@ const SORTABLE_COLUMNS = {
   VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
 
   ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
-  SERVERS_BLOCKLIST: [ 'createdAt' ]
+  SERVERS_BLOCKLIST: [ 'createdAt' ],
+
+  USER_NOTIFICATIONS: [ 'createdAt' ]
 }
 
 const OAUTH_LIFETIME = {
index 40cd659ab8bdf7d0708c8bb9904413297c1631aa..84ad2079b94767ec790a54937ca4b03acba86159 100644 (file)
@@ -31,6 +31,8 @@ import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
 import { UserVideoHistoryModel } from '../models/account/user-video-history'
 import { AccountBlocklistModel } from '../models/account/account-blocklist'
 import { ServerBlocklistModel } from '../models/server/server-blocklist'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -95,7 +97,9 @@ async function initDatabaseModels (silent: boolean) {
     VideoRedundancyModel,
     UserVideoHistoryModel,
     AccountBlocklistModel,
-    ServerBlocklistModel
+    ServerBlocklistModel,
+    UserNotificationModel,
+    UserNotificationSettingModel
   ])
 
   // Check extensions exist in the database
index cc88b5423702f0be03dfd8d563b054e3b07c95e2..23310b41e8de4b4923d1a0d0b282ad4b86e1541b 100644 (file)
@@ -5,6 +5,8 @@ import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { getOrCreateVideoAndAccountAndChannel } from '../videos'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { Notifier } from '../../notifier'
 
 async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
   return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
@@ -21,9 +23,9 @@ export {
 async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
   const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
+  const { video, created: videoCreated } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
 
-  return sequelizeTypescript.transaction(async t => {
+  await sequelizeTypescript.transaction(async t => {
     // Add share entry
 
     const share = {
@@ -49,4 +51,6 @@ async function processVideoShare (actorAnnouncer: ActorModel, activity: Activity
 
     return undefined
   })
+
+  if (videoCreated) Notifier.Instance.notifyOnNewVideo(video)
 }
index df05ee452d318a6be78a0deb04946a00f2f796de..2e04ee843acfc6963aef866a5ab025358bccd458 100644 (file)
@@ -13,6 +13,7 @@ import { forwardVideoRelatedActivity } from '../send/utils'
 import { Redis } from '../../redis'
 import { createOrUpdateCacheFile } from '../cache-file'
 import { getVideoDislikeActivityPubUrl } from '../url'
+import { Notifier } from '../../notifier'
 
 async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
   const activityObject = activity.object
@@ -47,7 +48,9 @@ export {
 async function processCreateVideo (activity: ActivityCreate) {
   const videoToCreateData = activity.object as VideoTorrentObject
 
-  const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
+  const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
+
+  if (created) Notifier.Instance.notifyOnNewVideo(video)
 
   return video
 }
@@ -133,7 +136,10 @@ async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateD
       state: VideoAbuseState.PENDING
     }
 
-    await VideoAbuseModel.create(videoAbuseData, { transaction: t })
+    const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
+    videoAbuseInstance.Video = video
+
+    Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
 
     logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object)
   })
@@ -147,7 +153,7 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
 
   const { video } = await resolveThread(commentObject.inReplyTo)
 
-  const { created } = await addVideoComment(video, commentObject.id)
+  const { comment, created } = await addVideoComment(video, commentObject.id)
 
   if (video.isOwned() && created === true) {
     // Don't resend the activity to the sender
@@ -155,4 +161,6 @@ async function processCreateVideoComment (byActor: ActorModel, activity: Activit
 
     await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
   }
+
+  if (created === true) Notifier.Instance.notifyOnNewComment(comment)
 }
index 5868e7297e5fd1b31b2aec448fa24209b79545f8..e87301fe7d0b25250d67579de96ca5680828bd82 100644 (file)
@@ -70,7 +70,7 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
     throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`)
   }
 
-  const actor = await getOrCreateActorAndServerAndModel(actorUrl)
+  const actor = await getOrCreateActorAndServerAndModel(actorUrl, 'all')
   const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body)
   if (!entry) return { created: false }
 
@@ -80,6 +80,8 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) {
     },
     defaults: entry
   })
+  comment.Account = actor.Account
+  comment.Video = videoInstance
 
   return { comment, created }
 }
index 379c2a0d7b93c81058f52264370e970e6c88db03..5794988a516e826f11cf68383c75d9b9434ec351 100644 (file)
@@ -29,6 +29,7 @@ import { addVideoShares, shareVideoByServerAndChannel } from './share'
 import { AccountModel } from '../../models/account/account'
 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
 import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
+import { Notifier } from '../notifier'
 
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
   // If the video is not private and published, we federate it
@@ -181,7 +182,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
       else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
     }
 
-    return { video: videoFromDatabase }
+    return { video: videoFromDatabase, created: false }
   }
 
   const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
@@ -192,7 +193,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
 
   await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
 
-  return { video }
+  return { video, created: true }
 }
 
 async function updateVideoFromAP (options: {
@@ -213,6 +214,9 @@ async function updateVideoFromAP (options: {
 
       videoFieldsSave = options.video.toJSON()
 
+      const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
+      const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
+
       // Check actor has the right to update the video
       const videoChannel = options.video.VideoChannel
       if (videoChannel.Account.id !== options.account.id) {
@@ -277,6 +281,13 @@ async function updateVideoFromAP (options: {
         })
         options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
       }
+
+      {
+        // Notify our users?
+        if (wasPrivateVideo || wasUnlistedVideo) {
+          Notifier.Instance.notifyOnNewVideo(options.video)
+        }
+      }
     })
 
     logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
index 2db3f8a34cbbe11fbd8b105894f34299fd1916ea..1875ec1fc8be73b20c8b6d359f3b8aea306d5b76 100644 (file)
@@ -115,8 +115,8 @@ export class ClientHtml {
   }
 
   private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
-    const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName()
-    const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
+    const previewUrl = CONFIG.WEBSERVER.URL + video.getPreviewStaticPath()
+    const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
 
     const videoNameEscaped = escapeHTML(video.name)
     const videoDescriptionEscaped = escapeHTML(video.description)
index 074d4ad44b8c50aebcba69cb3ff45062bc7f430f..d766e655b2d902763320a7800a665650a5f30486 100644 (file)
@@ -1,5 +1,4 @@
 import { createTransport, Transporter } from 'nodemailer'
-import { UserRight } from '../../shared/models/users'
 import { isTestInstance } from '../helpers/core-utils'
 import { bunyanLogger, logger } from '../helpers/logger'
 import { CONFIG } from '../initializers'
@@ -8,6 +7,9 @@ import { VideoModel } from '../models/video/video'
 import { JobQueue } from './job-queue'
 import { EmailPayload } from './job-queue/handlers/email'
 import { readFileSync } from 'fs-extra'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { VideoAbuseModel } from '../models/video/video-abuse'
+import { VideoBlacklistModel } from '../models/video/video-blacklist'
 
 class Emailer {
 
@@ -79,50 +81,57 @@ class Emailer {
     }
   }
 
-  addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
+  addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) {
+    const channelName = video.VideoChannel.getDisplayName()
+    const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
+
     const text = `Hi dear user,\n\n` +
-      `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
-      `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
-      `If you are not the person who initiated this request, please ignore this email.\n\n` +
+      `Your subscription ${channelName} just published a new video: ${video.name}` +
+      `\n\n` +
+      `You can view it on ${videoUrl} ` +
+      `\n\n` +
       `Cheers,\n` +
       `PeerTube.`
 
     const emailPayload: EmailPayload = {
-      to: [ to ],
-      subject: 'Reset your PeerTube password',
+      to,
+      subject: channelName + ' just published a new video',
       text
     }
 
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  addVerifyEmailJob (to: string, verifyEmailUrl: string) {
-    const text = `Welcome to PeerTube,\n\n` +
-      `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must  verify your email! ` +
-      `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
-      `If you are not the person who initiated this request, please ignore this email.\n\n` +
+  addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) {
+    const accountName = comment.Account.getDisplayName()
+    const video = comment.Video
+    const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
+
+    const text = `Hi dear user,\n\n` +
+      `A new comment has been posted by ${accountName} on your video ${video.name}` +
+      `\n\n` +
+      `You can view it on ${commentUrl} ` +
+      `\n\n` +
       `Cheers,\n` +
       `PeerTube.`
 
     const emailPayload: EmailPayload = {
-      to: [ to ],
-      subject: 'Verify your PeerTube email',
+      to,
+      subject: 'New comment on your video ' + video.name,
       text
     }
 
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoAbuseReportJob (videoId: number) {
-    const video = await VideoModel.load(videoId)
-    if (!video) throw new Error('Unknown Video id during Abuse report.')
+  async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
+    const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
 
     const text = `Hi,\n\n` +
-      `Your instance received an abuse for the following video ${video.url}\n\n` +
+      `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` +
       `Cheers,\n` +
       `PeerTube.`
 
-    const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES)
     const emailPayload: EmailPayload = {
       to,
       subject: '[PeerTube] Received a video abuse',
@@ -132,16 +141,12 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoBlacklistReportJob (videoId: number, reason?: string) {
-    const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
-    if (!video) throw new Error('Unknown Video id during Blacklist report.')
-    // It's not our user
-    if (video.remote === true) return
+  async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
+    const videoName = videoBlacklist.Video.name
+    const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
 
-    const user = await UserModel.loadById(video.VideoChannel.Account.userId)
-
-    const reasonString = reason ? ` for the following reason: ${reason}` : ''
-    const blockedString = `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
+    const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
+    const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
 
     const text = 'Hi,\n\n' +
       blockedString +
@@ -149,33 +154,26 @@ class Emailer {
       'Cheers,\n' +
       `PeerTube.`
 
-    const to = user.email
     const emailPayload: EmailPayload = {
-      to: [ to ],
-      subject: `[PeerTube] Video ${video.name} blacklisted`,
+      to,
+      subject: `[PeerTube] Video ${videoName} blacklisted`,
       text
     }
 
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  async addVideoUnblacklistReportJob (videoId: number) {
-    const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
-    if (!video) throw new Error('Unknown Video id during Blacklist report.')
-    // It's not our user
-    if (video.remote === true) return
-
-    const user = await UserModel.loadById(video.VideoChannel.Account.userId)
+  async addVideoUnblacklistNotification (to: string[], video: VideoModel) {
+    const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
 
     const text = 'Hi,\n\n' +
-      `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
+      `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
       '\n\n' +
       'Cheers,\n' +
       `PeerTube.`
 
-    const to = user.email
     const emailPayload: EmailPayload = {
-      to: [ to ],
+      to,
       subject: `[PeerTube] Video ${video.name} unblacklisted`,
       text
     }
@@ -183,6 +181,40 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
+  addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
+    const text = `Hi dear user,\n\n` +
+      `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
+      `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
+      `If you are not the person who initiated this request, please ignore this email.\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to: [ to ],
+      subject: 'Reset your PeerTube password',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
+  addVerifyEmailJob (to: string, verifyEmailUrl: string) {
+    const text = `Welcome to PeerTube,\n\n` +
+      `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must  verify your email! ` +
+      `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
+      `If you are not the person who initiated this request, please ignore this email.\n\n` +
+      `Cheers,\n` +
+      `PeerTube.`
+
+    const emailPayload: EmailPayload = {
+      to: [ to ],
+      subject: 'Verify your PeerTube email',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
   addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) {
     const reasonString = reason ? ` for the following reason: ${reason}` : ''
     const blockedWord = blocked ? 'blocked' : 'unblocked'
@@ -205,7 +237,7 @@ class Emailer {
   }
 
   sendMail (to: string[], subject: string, text: string) {
-    if (!this.transporter) {
+    if (!this.enabled) {
       throw new Error('Cannot send mail because SMTP is not configured.')
     }
 
index 3dca2937f6bfe77459a61d2cd0f145e1d0e9591f..959cc04fa0b3b69849027ba4b425dc37f0f04fb5 100644 (file)
@@ -9,6 +9,7 @@ import { sequelizeTypescript } from '../../../initializers'
 import * as Bluebird from 'bluebird'
 import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
 import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding'
+import { Notifier } from '../../notifier'
 
 export type VideoFilePayload = {
   videoUUID: string
@@ -86,6 +87,7 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
 
     // If the video was not published, we consider it is a new one for other instances
     await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+    if (isNewVideo) Notifier.Instance.notifyOnNewVideo(video)
 
     return undefined
   })
@@ -134,7 +136,8 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo
       logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
     }
 
-    return federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+    await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+    if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
   })
 }
 
index 63aacff98fcfea4cd4c290518db8da38867fe974..82edb8d5c0001476efeaa1fed41f2959ef42ac69 100644 (file)
@@ -15,6 +15,7 @@ import { VideoModel } from '../../../models/video/video'
 import { downloadWebTorrentVideo } from '../../../helpers/webtorrent'
 import { getSecureTorrentName } from '../../../helpers/utils'
 import { remove, move, stat } from 'fs-extra'
+import { Notifier } from '../../notifier'
 
 type VideoImportYoutubeDLPayload = {
   type: 'youtube-dl'
@@ -184,6 +185,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
       // Now we can federate the video (reload from database, we need more attributes)
       const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
       await federateVideoIfNeeded(videoForFederation, true, t)
+      Notifier.Instance.notifyOnNewVideo(videoForFederation)
 
       // Update video import object
       videoImport.state = VideoImportState.SUCCESS
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
new file mode 100644 (file)
index 0000000..a21b50b
--- /dev/null
@@ -0,0 +1,235 @@
+import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
+import { logger } from '../helpers/logger'
+import { VideoModel } from '../models/video/video'
+import { Emailer } from './emailer'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { UserModel } from '../models/account/user'
+import { PeerTubeSocket } from './peertube-socket'
+import { CONFIG } from '../initializers/constants'
+import { VideoPrivacy, VideoState } from '../../shared/models/videos'
+import { VideoAbuseModel } from '../models/video/video-abuse'
+import { VideoBlacklistModel } from '../models/video/video-blacklist'
+import * as Bluebird from 'bluebird'
+
+class Notifier {
+
+  private static instance: Notifier
+
+  private constructor () {}
+
+  notifyOnNewVideo (video: VideoModel): void {
+    // Only notify on public and published videos
+    if (video.privacy !== VideoPrivacy.PUBLIC || video.state !== VideoState.PUBLISHED) return
+
+    this.notifySubscribersOfNewVideo(video)
+      .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
+  }
+
+  notifyOnNewComment (comment: VideoCommentModel): void {
+    this.notifyVideoOwnerOfNewComment(comment)
+        .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err }))
+  }
+
+  notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
+    this.notifyModeratorsOfNewVideoAbuse(videoAbuse)
+      .catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err }))
+  }
+
+  notifyOnVideoBlacklist (videoBlacklist: VideoBlacklistModel): void {
+    this.notifyVideoOwnerOfBlacklist(videoBlacklist)
+      .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
+  }
+
+  notifyOnVideoUnblacklist (video: VideoModel): void {
+    this.notifyVideoOwnerOfUnblacklist(video)
+        .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', video.url, { err }))
+  }
+
+  private async notifySubscribersOfNewVideo (video: VideoModel) {
+    // List all followers that are users
+    const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
+
+    logger.info('Notifying %d users of new video %s.', users.length, video.url)
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.newVideoFromSubscription
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
+        userId: user.id,
+        videoId: video.id
+      })
+      notification.Video = video
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addNewVideoFromSubscriberNotification(emails, video)
+    }
+
+    return this.notify({ users, settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
+    const user = await UserModel.loadByVideoId(comment.videoId)
+
+    // Not our user or user comments its own video
+    if (!user || comment.Account.userId === user.id) return
+
+    logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.newCommentOnMyVideo
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
+        userId: user.id,
+        commentId: comment.id
+      })
+      notification.Comment = comment
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addNewCommentOnMyVideoNotification(emails, comment)
+    }
+
+    return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
+    const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
+    if (users.length === 0) return
+
+    logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url)
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.videoAbuseAsModerator
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
+        userId: user.id,
+        videoAbuseId: videoAbuse.id
+      })
+      notification.VideoAbuse = videoAbuse
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
+    }
+
+    return this.notify({ users, settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
+    const user = await UserModel.loadByVideoId(videoBlacklist.videoId)
+    if (!user) return
+
+    logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.blacklistOnMyVideo
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
+        userId: user.id,
+        videoBlacklistId: videoBlacklist.id
+      })
+      notification.VideoBlacklist = videoBlacklist
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addVideoBlacklistNotification(emails, videoBlacklist)
+    }
+
+    return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notifyVideoOwnerOfUnblacklist (video: VideoModel) {
+    const user = await UserModel.loadByVideoId(video.id)
+    if (!user) return
+
+    logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
+
+    function settingGetter (user: UserModel) {
+      return user.NotificationSetting.blacklistOnMyVideo
+    }
+
+    async function notificationCreator (user: UserModel) {
+      const notification = await UserNotificationModel.create({
+        type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
+        userId: user.id,
+        videoId: video.id
+      })
+      notification.Video = video
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addVideoUnblacklistNotification(emails, video)
+    }
+
+    return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+  }
+
+  private async notify (options: {
+    users: UserModel[],
+    notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
+    emailSender: (emails: string[]) => Promise<any> | Bluebird<any>,
+    settingGetter: (user: UserModel) => UserNotificationSettingValue
+  }) {
+    const emails: string[] = []
+
+    for (const user of options.users) {
+      if (this.isWebNotificationEnabled(options.settingGetter(user))) {
+        const notification = await options.notificationCreator(user)
+
+        PeerTubeSocket.Instance.sendNotification(user.id, notification)
+      }
+
+      if (this.isEmailEnabled(user, options.settingGetter(user))) {
+        emails.push(user.email)
+      }
+    }
+
+    if (emails.length !== 0) {
+      await options.emailSender(emails)
+    }
+  }
+
+  private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
+    if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
+
+    return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+  }
+
+  private isWebNotificationEnabled (value: UserNotificationSettingValue) {
+    return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  Notifier
+}
index 5cbe60b82c626ca8148ee5545d2da7a05c90fcf3..2cd2ae97cf6c9978837f4ac054d4d968a64e205b 100644 (file)
@@ -1,3 +1,4 @@
+import * as Bluebird from 'bluebird'
 import { AccessDeniedError } from 'oauth2-server'
 import { logger } from '../helpers/logger'
 import { UserModel } from '../models/account/user'
@@ -37,7 +38,7 @@ function clearCacheByToken (token: string) {
 function getAccessToken (bearerToken: string) {
   logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
 
-  if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken]
+  if (accessTokenCache[bearerToken] !== undefined) return Bluebird.resolve(accessTokenCache[bearerToken])
 
   return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
     .then(tokenModel => {
diff --git a/server/lib/peertube-socket.ts b/server/lib/peertube-socket.ts
new file mode 100644 (file)
index 0000000..eb84ecd
--- /dev/null
@@ -0,0 +1,52 @@
+import * as SocketIO from 'socket.io'
+import { authenticateSocket } from '../middlewares'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { logger } from '../helpers/logger'
+import { Server } from 'http'
+
+class PeerTubeSocket {
+
+  private static instance: PeerTubeSocket
+
+  private userNotificationSockets: { [ userId: number ]: SocketIO.Socket } = {}
+
+  private constructor () {}
+
+  init (server: Server) {
+    const io = SocketIO(server)
+
+    io.of('/user-notifications')
+      .use(authenticateSocket)
+      .on('connection', socket => {
+        const userId = socket.handshake.query.user.id
+
+        logger.debug('User %d connected on the notification system.', userId)
+
+        this.userNotificationSockets[userId] = socket
+
+        socket.on('disconnect', () => {
+          logger.debug('User %d disconnected from SocketIO notifications.', userId)
+
+          delete this.userNotificationSockets[userId]
+        })
+      })
+  }
+
+  sendNotification (userId: number, notification: UserNotificationModel) {
+    const socket = this.userNotificationSockets[userId]
+
+    if (!socket) return
+
+    socket.emit('new-notification', notification.toFormattedJSON())
+  }
+
+  static get Instance () {
+    return this.instance || (this.instance = new this())
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  PeerTubeSocket
+}
index 21f071f9eed4f47552670068181b22e57661ffe2..b7fb029f1bd6d6fd9c89dd154d01b298a694ee3c 100644 (file)
@@ -5,6 +5,7 @@ import { retryTransactionWrapper } from '../../helpers/database-utils'
 import { federateVideoIfNeeded } from '../activitypub'
 import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
 import { VideoPrivacy } from '../../../shared/models/videos'
+import { Notifier } from '../notifier'
 
 export class UpdateVideosScheduler extends AbstractScheduler {
 
@@ -39,6 +40,10 @@ export class UpdateVideosScheduler extends AbstractScheduler {
 
           await video.save({ transaction: t })
           await federateVideoIfNeeded(video, isNewVideo, t)
+
+          if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) {
+            Notifier.Instance.notifyOnNewVideo(video)
+          }
         }
 
         await schedule.destroy({ transaction: t })
index 29d6d087d1bbde1821815a863f738f0de793d8be..72127819c0d5bff9e561f772d825f28626c57b1c 100644 (file)
@@ -9,6 +9,8 @@ import { createVideoChannel } from './video-channel'
 import { VideoChannelModel } from '../models/video/video-channel'
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
 import { ActorModel } from '../models/activitypub/actor'
+import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
+import { UserNotificationSettingValue } from '../../shared/models/users'
 
 async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
   const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@@ -18,6 +20,8 @@ async function createUserAccountAndChannel (userToCreate: UserModel, validateUse
     }
 
     const userCreated = await userToCreate.save(userOptions)
+    userCreated.NotificationSetting = await createDefaultUserNotificationSettings(userCreated, t)
+
     const accountCreated = await createLocalAccountWithoutKeys(userCreated.username, userCreated.id, null, t)
     userCreated.Account = accountCreated
 
@@ -88,3 +92,15 @@ export {
   createUserAccountAndChannel,
   createLocalAccountWithoutKeys
 }
+
+// ---------------------------------------------------------------------------
+
+function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
+  return UserNotificationSettingModel.create({
+    userId: user.id,
+    newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
+    newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
+    videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+    blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+  }, { transaction: t })
+}
index 8c1df2c3eaa2688f5edaa302aa49c5b9154d3b48..1d193d467455b675f5a4d4e117555ff56649b17b 100644 (file)
@@ -3,6 +3,8 @@ import * as OAuthServer from 'express-oauth-server'
 import 'express-validator'
 import { OAUTH_LIFETIME } from '../initializers'
 import { logger } from '../helpers/logger'
+import { Socket } from 'socket.io'
+import { getAccessToken } from '../lib/oauth-model'
 
 const oAuthServer = new OAuthServer({
   useErrorHandler: true,
@@ -28,6 +30,25 @@ function authenticate (req: express.Request, res: express.Response, next: expres
   })
 }
 
+function authenticateSocket (socket: Socket, next: (err?: any) => void) {
+  const accessToken = socket.handshake.query.accessToken
+
+  logger.debug('Checking socket access token %s.', accessToken)
+
+  getAccessToken(accessToken)
+    .then(tokenDB => {
+      const now = new Date()
+
+      if (!tokenDB || tokenDB.accessTokenExpiresAt < now || tokenDB.refreshTokenExpiresAt < now) {
+        return next(new Error('Invalid access token.'))
+      }
+
+      socket.handshake.query.user = tokenDB.User
+
+      return next()
+    })
+}
+
 function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) {
   return new Promise(resolve => {
     // Already authenticated? (or tried to)
@@ -68,6 +89,7 @@ function token (req: express.Request, res: express.Response, next: express.NextF
 
 export {
   authenticate,
+  authenticateSocket,
   authenticatePromiseIfNeeded,
   optionalAuthenticate,
   token
index 4c0577d8f6d84255ff013ad4c97c4d5b8d1d4985..5ceda845fb263bfb6058f9210056f5fcc3c9f41a 100644 (file)
@@ -18,6 +18,7 @@ const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOW
 const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
 const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
 const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
+const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
 
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -35,6 +36,7 @@ const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS)
 const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
 const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
 const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
+const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
 
 // ---------------------------------------------------------------------------
 
@@ -54,5 +56,6 @@ export {
   userSubscriptionsSortValidator,
   videoChannelsSearchSortValidator,
   accountsBlocklistSortValidator,
-  serversBlocklistSortValidator
+  serversBlocklistSortValidator,
+  userNotificationsSortValidator
 }
index 3c8971ea18b8adecd00c2e366ea1eeee4014bdbd..418313d09812d8a365f757b0b15462c05d54e49c 100644 (file)
@@ -1,13 +1,9 @@
 import * as express from 'express'
 import 'express-validator'
-import { body, param, query } from 'express-validator/check'
+import { body } from 'express-validator/check'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
-import { ActorFollowModel } from '../../models/activitypub/actor-follow'
-import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
-import { UserModel } from '../../models/account/user'
-import { CONFIG } from '../../initializers'
-import { isDateValid, toArray } from '../../helpers/custom-validators/misc'
+import { isDateValid } from '../../helpers/custom-validators/misc'
 
 const userHistoryRemoveValidator = [
   body('beforeDate')
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
new file mode 100644 (file)
index 0000000..8202f30
--- /dev/null
@@ -0,0 +1,46 @@
+import * as express from 'express'
+import 'express-validator'
+import { body } from 'express-validator/check'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
+import { isIntArray } from '../../helpers/custom-validators/misc'
+
+const updateNotificationSettingsValidator = [
+  body('newVideoFromSubscription')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
+  body('newCommentOnMyVideo')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
+  body('videoAbuseAsModerator')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'),
+  body('blacklistOnMyVideo')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new blacklist on my video notification setting'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking updateNotificationSettingsValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+const markAsReadUserNotificationsValidator = [
+  body('ids')
+    .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  updateNotificationSettingsValidator,
+  markAsReadUserNotificationsValidator
+}
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
new file mode 100644 (file)
index 0000000..bc24b1e
--- /dev/null
@@ -0,0 +1,100 @@
+import {
+  AfterDestroy,
+  AfterUpdate,
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  Default,
+  ForeignKey,
+  Is,
+  Model,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { throwIfNotValid } from '../utils'
+import { UserModel } from './user'
+import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
+import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
+import { clearCacheByUserId } from '../../lib/oauth-model'
+
+@Table({
+  tableName: 'userNotificationSetting',
+  indexes: [
+    {
+      fields: [ 'userId' ],
+      unique: true
+    }
+  ]
+})
+export class UserNotificationSettingModel extends Model<UserNotificationSettingModel> {
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingNewVideoFromSubscription',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
+  )
+  @Column
+  newVideoFromSubscription: UserNotificationSettingValue
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingNewCommentOnMyVideo',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
+  )
+  @Column
+  newCommentOnMyVideo: UserNotificationSettingValue
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingVideoAbuseAsModerator',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
+  )
+  @Column
+  videoAbuseAsModerator: UserNotificationSettingValue
+
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingBlacklistOnMyVideo',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
+  )
+  @Column
+  blacklistOnMyVideo: UserNotificationSettingValue
+
+  @ForeignKey(() => UserModel)
+  @Column
+  userId: number
+
+  @BelongsTo(() => UserModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  User: UserModel
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AfterUpdate
+  @AfterDestroy
+  static removeTokenCache (instance: UserNotificationSettingModel) {
+    return clearCacheByUserId(instance.userId)
+  }
+
+  toFormattedJSON (): UserNotificationSetting {
+    return {
+      newCommentOnMyVideo: this.newCommentOnMyVideo,
+      newVideoFromSubscription: this.newVideoFromSubscription,
+      videoAbuseAsModerator: this.videoAbuseAsModerator,
+      blacklistOnMyVideo: this.blacklistOnMyVideo
+    }
+  }
+}
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
new file mode 100644 (file)
index 0000000..e22f0d5
--- /dev/null
@@ -0,0 +1,256 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { UserNotification, UserNotificationType } from '../../../shared'
+import { getSort, throwIfNotValid } from '../utils'
+import { isBooleanValid } from '../../helpers/custom-validators/misc'
+import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
+import { UserModel } from './user'
+import { VideoModel } from '../video/video'
+import { VideoCommentModel } from '../video/video-comment'
+import { Op } from 'sequelize'
+import { VideoChannelModel } from '../video/video-channel'
+import { AccountModel } from './account'
+import { VideoAbuseModel } from '../video/video-abuse'
+import { VideoBlacklistModel } from '../video/video-blacklist'
+
+enum ScopeNames {
+  WITH_ALL = 'WITH_ALL'
+}
+
+@Scopes({
+  [ScopeNames.WITH_ALL]: {
+    include: [
+      {
+        attributes: [ 'id', 'uuid', 'name' ],
+        model: () => VideoModel.unscoped(),
+        required: false,
+        include: [
+          {
+            required: true,
+            attributes: [ 'id', 'name' ],
+            model: () => VideoChannelModel.unscoped()
+          }
+        ]
+      },
+      {
+        attributes: [ 'id' ],
+        model: () => VideoCommentModel.unscoped(),
+        required: false,
+        include: [
+          {
+            required: true,
+            attributes: [ 'id', 'name' ],
+            model: () => AccountModel.unscoped()
+          },
+          {
+            required: true,
+            attributes: [ 'id', 'uuid', 'name' ],
+            model: () => VideoModel.unscoped()
+          }
+        ]
+      },
+      {
+        attributes: [ 'id' ],
+        model: () => VideoAbuseModel.unscoped(),
+        required: false,
+        include: [
+          {
+            required: true,
+            attributes: [ 'id', 'uuid', 'name' ],
+            model: () => VideoModel.unscoped()
+          }
+        ]
+      },
+      {
+        attributes: [ 'id' ],
+        model: () => VideoBlacklistModel.unscoped(),
+        required: false,
+        include: [
+          {
+            required: true,
+            attributes: [ 'id', 'uuid', 'name' ],
+            model: () => VideoModel.unscoped()
+          }
+        ]
+      }
+    ]
+  }
+})
+@Table({
+  tableName: 'userNotification',
+  indexes: [
+    {
+      fields: [ 'videoId' ]
+    },
+    {
+      fields: [ 'commentId' ]
+    }
+  ]
+})
+export class UserNotificationModel extends Model<UserNotificationModel> {
+
+  @AllowNull(false)
+  @Default(null)
+  @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
+  @Column
+  type: UserNotificationType
+
+  @AllowNull(false)
+  @Default(false)
+  @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
+  @Column
+  read: boolean
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => UserModel)
+  @Column
+  userId: number
+
+  @BelongsTo(() => UserModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  User: UserModel
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  Video: VideoModel
+
+  @ForeignKey(() => VideoCommentModel)
+  @Column
+  commentId: number
+
+  @BelongsTo(() => VideoCommentModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  Comment: VideoCommentModel
+
+  @ForeignKey(() => VideoAbuseModel)
+  @Column
+  videoAbuseId: number
+
+  @BelongsTo(() => VideoAbuseModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  VideoAbuse: VideoAbuseModel
+
+  @ForeignKey(() => VideoBlacklistModel)
+  @Column
+  videoBlacklistId: number
+
+  @BelongsTo(() => VideoBlacklistModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'cascade'
+  })
+  VideoBlacklist: VideoBlacklistModel
+
+  static listForApi (userId: number, start: number, count: number, sort: string) {
+    const query = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      where: {
+        userId
+      }
+    }
+
+    return UserNotificationModel.scope(ScopeNames.WITH_ALL)
+                                .findAndCountAll(query)
+                                .then(({ rows, count }) => {
+                                  return {
+                                    data: rows,
+                                    total: count
+                                  }
+                                })
+  }
+
+  static markAsRead (userId: number, notificationIds: number[]) {
+    const query = {
+      where: {
+        userId,
+        id: {
+          [Op.any]: notificationIds
+        }
+      }
+    }
+
+    return UserNotificationModel.update({ read: true }, query)
+  }
+
+  toFormattedJSON (): UserNotification {
+    const video = this.Video ? {
+      id: this.Video.id,
+      uuid: this.Video.uuid,
+      name: this.Video.name,
+      channel: {
+        id: this.Video.VideoChannel.id,
+        displayName: this.Video.VideoChannel.getDisplayName()
+      }
+    } : undefined
+
+    const comment = this.Comment ? {
+      id: this.Comment.id,
+      account: {
+        id: this.Comment.Account.id,
+        displayName: this.Comment.Account.getDisplayName()
+      },
+      video: {
+        id: this.Comment.Video.id,
+        uuid: this.Comment.Video.uuid,
+        name: this.Comment.Video.name
+      }
+    } : undefined
+
+    const videoAbuse = this.VideoAbuse ? {
+      id: this.VideoAbuse.id,
+      video: {
+        id: this.VideoAbuse.Video.id,
+        uuid: this.VideoAbuse.Video.uuid,
+        name: this.VideoAbuse.Video.name
+      }
+    } : undefined
+
+    const videoBlacklist = this.VideoBlacklist ? {
+      id: this.VideoBlacklist.id,
+      video: {
+        id: this.VideoBlacklist.Video.id,
+        uuid: this.VideoBlacklist.Video.uuid,
+        name: this.VideoBlacklist.Video.name
+      }
+    } : undefined
+
+    return {
+      id: this.id,
+      type: this.type,
+      read: this.read,
+      video,
+      comment,
+      videoAbuse,
+      videoBlacklist,
+      createdAt: this.createdAt.toISOString(),
+      updatedAt: this.updatedAt.toISOString()
+    }
+  }
+}
index 180ced81072cbd354ed73547543dd3a2ad8f1b72..55ec14d0568a2b378630a142c93f16cff399d38c 100644 (file)
@@ -32,8 +32,8 @@ import {
   isUserUsernameValid,
   isUserVideoQuotaDailyValid,
   isUserVideoQuotaValid,
-  isUserWebTorrentEnabledValid,
-  isUserVideosHistoryEnabledValid
+  isUserVideosHistoryEnabledValid,
+  isUserWebTorrentEnabledValid
 } from '../../helpers/custom-validators/users'
 import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
 import { OAuthTokenModel } from '../oauth/oauth-token'
@@ -44,6 +44,10 @@ import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
 import { values } from 'lodash'
 import { NSFW_POLICY_TYPES } from '../../initializers'
 import { clearCacheByUserId } from '../../lib/oauth-model'
+import { UserNotificationSettingModel } from './user-notification-setting'
+import { VideoModel } from '../video/video'
+import { ActorModel } from '../activitypub/actor'
+import { ActorFollowModel } from '../activitypub/actor-follow'
 
 enum ScopeNames {
   WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -54,6 +58,10 @@ enum ScopeNames {
     {
       model: () => AccountModel,
       required: true
+    },
+    {
+      model: () => UserNotificationSettingModel,
+      required: true
     }
   ]
 })
@@ -64,6 +72,10 @@ enum ScopeNames {
         model: () => AccountModel,
         required: true,
         include: [ () => VideoChannelModel ]
+      },
+      {
+        model: () => UserNotificationSettingModel,
+        required: true
       }
     ]
   }
@@ -167,6 +179,13 @@ export class UserModel extends Model<UserModel> {
   })
   Account: AccountModel
 
+  @HasOne(() => UserNotificationSettingModel, {
+    foreignKey: 'userId',
+    onDelete: 'cascade',
+    hooks: true
+  })
+  NotificationSetting: UserNotificationSettingModel
+
   @HasMany(() => OAuthTokenModel, {
     foreignKey: 'userId',
     onDelete: 'cascade'
@@ -249,13 +268,12 @@ export class UserModel extends Model<UserModel> {
       })
   }
 
-  static listEmailsWithRight (right: UserRight) {
+  static listWithRight (right: UserRight) {
     const roles = Object.keys(USER_ROLE_LABELS)
       .map(k => parseInt(k, 10) as UserRole)
       .filter(role => hasUserRight(role, right))
 
     const query = {
-      attribute: [ 'email' ],
       where: {
         role: {
           [Sequelize.Op.in]: roles
@@ -263,9 +281,46 @@ export class UserModel extends Model<UserModel> {
       }
     }
 
-    return UserModel.unscoped()
-      .findAll(query)
-      .then(u => u.map(u => u.email))
+    return UserModel.findAll(query)
+  }
+
+  static listUserSubscribersOf (actorId: number) {
+    const query = {
+      include: [
+        {
+          model: UserNotificationSettingModel.unscoped(),
+          required: true
+        },
+        {
+          attributes: [ 'userId' ],
+          model: AccountModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ ],
+              model: ActorModel.unscoped(),
+              required: true,
+              where: {
+                serverId: null
+              },
+              include: [
+                {
+                  attributes: [ ],
+                  as: 'ActorFollowings',
+                  model: ActorFollowModel.unscoped(),
+                  required: true,
+                  where: {
+                    targetActorId: actorId
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+
+    return UserModel.unscoped().findAll(query)
   }
 
   static loadById (id: number) {
@@ -314,6 +369,37 @@ export class UserModel extends Model<UserModel> {
     return UserModel.findOne(query)
   }
 
+  static loadByVideoId (videoId: number) {
+    const query = {
+      include: [
+        {
+          required: true,
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          include: [
+            {
+              required: true,
+              attributes: [ 'id' ],
+              model: VideoChannelModel.unscoped(),
+              include: [
+                {
+                  required: true,
+                  attributes: [ 'id' ],
+                  model: VideoModel.unscoped(),
+                  where: {
+                    id: videoId
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+
+    return UserModel.findOne(query)
+  }
+
   static getOriginalVideoFileTotalFromUser (user: UserModel) {
     // Don't use sequelize because we need to use a sub query
     const query = UserModel.generateUserQuotaBaseSQL()
@@ -380,6 +466,7 @@ export class UserModel extends Model<UserModel> {
       blocked: this.blocked,
       blockedReason: this.blockedReason,
       account: this.Account.toFormattedJSON(),
+      notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
       videoChannels: [],
       videoQuotaUsed: videoQuotaUsed !== undefined
             ? parseInt(videoQuotaUsed, 10)
index 994f791de2a6711f08074d04c562a9f583ff96ec..796e07a42d783b0cb120805ffac74b114effe095 100644 (file)
@@ -307,7 +307,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
       })
   }
 
-  static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) {
+  static listFollowersForApi (actorId: number, start: number, count: number, sort: string, search?: string) {
     const query = {
       distinct: true,
       offset: start,
@@ -335,7 +335,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
           as: 'ActorFollowing',
           required: true,
           where: {
-            id
+            id: actorId
           }
         }
       ]
@@ -350,7 +350,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
                            })
   }
 
-  static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
+  static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
     const query = {
       attributes: [],
       distinct: true,
@@ -358,7 +358,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
       limit: count,
       order: getSort(sort),
       where: {
-        actorId: id
+        actorId: actorId
       },
       include: [
         {
@@ -451,9 +451,9 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
   static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) {
     const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
       'WHERE id IN (' +
-      'SELECT "actorFollow"."id" FROM "actorFollow" ' +
-      'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
-      `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
+        'SELECT "actorFollow"."id" FROM "actorFollow" ' +
+        'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
+        `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
       ')'
 
     const options = {
index 12b83916e7c8ecc735ae22d87582e789475f742c..dda57a8ba172264933a03fee9bf9723c40debb05 100644 (file)
@@ -219,6 +219,7 @@ export class ActorModel extends Model<ActorModel> {
       name: 'actorId',
       allowNull: false
     },
+    as: 'ActorFollowings',
     onDelete: 'cascade'
   })
   ActorFollowing: ActorFollowModel[]
index dbb88ca4565cd197f0d3af90153b8a759dd2fba3..4c9e2d05e7edd1335d0d83d01b60486c144ced55 100644 (file)
@@ -86,11 +86,6 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   })
   Video: VideoModel
 
-  @AfterCreate
-  static sendEmailNotification (instance: VideoAbuseModel) {
-    return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
-  }
-
   static loadByIdAndVideoId (id: number, videoId: number) {
     const query = {
       where: {
index 67f7cd4871c0aeef041996ff11f557002e26ebfd..23e992685870615eb83ce910b58a9a12c3904694 100644 (file)
@@ -53,16 +53,6 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
   })
   Video: VideoModel
 
-  @AfterCreate
-  static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
-    return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
-  }
-
-  @AfterDestroy
-  static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
-    return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
-  }
-
   static listForApi (start: number, count: number, sort: SortType) {
     const query = {
       offset: start,
index dd6d08139158b295f350f435c053ea8a34e22d0d..d8fc2a564986f6c07674cf3315b7da81275a0cab 100644 (file)
@@ -448,6 +448,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     }
   }
 
+  getCommentStaticPath () {
+    return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
+  }
+
   getThreadId (): number {
     return this.originCommentId || this.id
   }
index bcf327f32a215dd7eca8e642f64681b082ece2ab..fc200e5d1af159420b271d68b07e3d2873fbe995 100644 (file)
@@ -1527,6 +1527,10 @@ export class VideoModel extends Model<VideoModel> {
     videoFile.infoHash = parsedTorrent.infoHash
   }
 
+  getWatchStaticPath () {
+    return '/videos/watch/' + this.uuid
+  }
+
   getEmbedStaticPath () {
     return '/videos/embed/' + this.uuid
   }
index 877ceb0a7671f73fa2f5c1fa010c6c2818693128..7a181d1d6429e2cf8d2fe4b78fa5c07a864a38ed 100644 (file)
@@ -7,6 +7,7 @@ import './jobs'
 import './redundancy'
 import './search'
 import './services'
+import './user-notifications'
 import './user-subscriptions'
 import './users'
 import './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 (file)
index 0000000..3ae36dd
--- /dev/null
@@ -0,0 +1,249 @@
+/* tslint:disable:no-unused-expression */
+
+import 'mocha'
+import * as io from 'socket.io-client'
+
+import {
+  flushTests,
+  immutableAssign,
+  killallServers,
+  makeGetRequest,
+  makePostBodyRequest,
+  makePutBodyRequest,
+  runServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  wait
+} from '../../../../shared/utils'
+import {
+  checkBadCountPagination,
+  checkBadSortPagination,
+  checkBadStartPagination
+} from '../../../../shared/utils/requests/check-api-params'
+import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users'
+
+describe('Test user notifications API validators', function () {
+  let server: ServerInfo
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    await flushTests()
+
+    server = await runServer(1)
+
+    await setAccessTokensToServers([ server ])
+  })
+
+  describe('When listing my notifications', function () {
+    const path = '/api/v1/users/me/notifications'
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should succeed with the correct parameters', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        token: server.accessToken,
+        statusCodeExpected: 200
+      })
+    })
+  })
+
+  describe('When marking as read my notifications', function () {
+    const path = '/api/v1/users/me/notifications/read'
+
+    it('Should fail with wrong ids parameters', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields: {
+          ids: [ 'hello' ]
+        },
+        token: server.accessToken,
+        statusCodeExpected: 400
+      })
+
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields: {
+          ids: 5
+        },
+        token: server.accessToken,
+        statusCodeExpected: 400
+      })
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields: {
+          ids: [ 5 ]
+        },
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should succeed with the correct parameters', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields: {
+          ids: [ 5 ]
+        },
+        token: server.accessToken,
+        statusCodeExpected: 204
+      })
+    })
+  })
+
+  describe('When updating my notification settings', function () {
+    const path = '/api/v1/users/me/notification-settings'
+    const correctFields: UserNotificationSetting = {
+      newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
+      newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
+      videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
+      blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION
+    }
+
+    it('Should fail with missing fields', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        token: server.accessToken,
+        fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION },
+        statusCodeExpected: 400
+      })
+    })
+
+    it('Should fail with incorrect field values', async function () {
+      {
+        const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 15 })
+
+        await makePutBodyRequest({
+          url: server.url,
+          path,
+          token: server.accessToken,
+          fields,
+          statusCodeExpected: 400
+        })
+      }
+
+      {
+        const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 'toto' })
+
+        await makePutBodyRequest({
+          url: server.url,
+          path,
+          fields,
+          token: server.accessToken,
+          statusCodeExpected: 400
+        })
+      }
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        fields: correctFields,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should succeed with the correct parameters', async function () {
+      await makePutBodyRequest({
+        url: server.url,
+        path,
+        token: server.accessToken,
+        fields: correctFields,
+        statusCodeExpected: 204
+      })
+    })
+  })
+
+  describe('When connecting to my notification socket', function () {
+    it('Should fail with no token', function (next) {
+      const socket = io('http://localhost:9001/user-notifications', { reconnection: false })
+
+      socket.on('error', () => {
+        socket.removeListener('error', this)
+        socket.disconnect()
+        next()
+      })
+
+      socket.on('connect', () => {
+        socket.disconnect()
+        next(new Error('Connected with a missing token.'))
+      })
+    })
+
+    it('Should fail with an invalid token', function (next) {
+      const socket = io('http://localhost:9001/user-notifications', {
+        query: { accessToken: 'bad_access_token' },
+        reconnection: false
+      })
+
+      socket.on('error', () => {
+        socket.removeListener('error', this)
+        socket.disconnect()
+        next()
+      })
+
+      socket.on('connect', () => {
+        socket.disconnect()
+        next(new Error('Connected with an invalid token.'))
+      })
+    })
+
+    it('Should success with the correct token', function (next) {
+      const socket = io('http://localhost:9001/user-notifications', {
+        query: { accessToken: server.accessToken },
+        reconnection: false
+      })
+
+      const errorListener = socket.on('error', err => {
+        next(new Error('Error in connection: ' + err))
+      })
+
+      socket.on('connect', async () => {
+        socket.removeListener('error', errorListener)
+        socket.disconnect()
+
+        await wait(500)
+        next()
+      })
+    })
+  })
+
+  after(async function () {
+    killallServers([ server ])
+
+    // Keep the logs if the test failed
+    if (this['ok']) {
+      await flushTests()
+    }
+  })
+})
index ff433315d38762071e2b256c95273d1840c7c722..63e6e827ab5510c84d644af038ec00daaa1bc7ca 100644 (file)
@@ -1,5 +1,6 @@
 import './blocklist'
 import './user-subscriptions'
+import './user-notifications'
 import './users'
 import './users-multiple-servers'
 import './users-verification'
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
new file mode 100644 (file)
index 0000000..ea35e63
--- /dev/null
@@ -0,0 +1,628 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  addVideoToBlacklist,
+  createUser,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  flushTests,
+  getMyUserInformation,
+  immutableAssign,
+  removeVideoFromBlacklist,
+  reportVideoAbuse,
+  updateVideo,
+  userLogin,
+  wait
+} from '../../../../shared/utils'
+import { killallServers, ServerInfo, uploadVideo } from '../../../../shared/utils/index'
+import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
+import { waitJobs } from '../../../../shared/utils/server/jobs'
+import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
+import {
+  CheckerBaseParams,
+  checkNewBlacklistOnMyVideo,
+  checkNewCommentOnMyVideo,
+  checkNewVideoAbuseForModerators,
+  checkNewVideoFromSubscription,
+  getLastNotification,
+  getUserNotifications,
+  markAsReadNotifications,
+  updateMyNotificationSettings
+} from '../../../../shared/utils/users/user-notifications'
+import { User, UserNotification, UserNotificationSettingValue } from '../../../../shared/models/users'
+import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
+import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
+import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
+
+const expect = chai.expect
+
+async function uploadVideoByRemoteAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
+  const data = Object.assign({ name: 'remote video ' + videoNameId }, additionalParams)
+  const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, data)
+
+  await waitJobs(servers)
+
+  return res.body.video.uuid
+}
+
+async function uploadVideoByLocalAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
+  const data = Object.assign({ name: 'local video ' + videoNameId }, additionalParams)
+  const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, data)
+
+  await waitJobs(servers)
+
+  return res.body.video.uuid
+}
+
+describe('Test users notifications', function () {
+  let servers: ServerInfo[] = []
+  let userAccessToken: string
+  let userNotifications: UserNotification[] = []
+  let adminNotifications: UserNotification[] = []
+  const emails: object[] = []
+
+  before(async function () {
+    this.timeout(120000)
+
+    await MockSmtpServer.Instance.collectEmails(emails)
+
+    await flushTests()
+
+    const overrideConfig = {
+      smtp: {
+        hostname: 'localhost'
+      }
+    }
+    servers = await flushAndRunMultipleServers(2, overrideConfig)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[0], servers[1])
+
+    await waitJobs(servers)
+
+    const user = {
+      username: 'user_1',
+      password: 'super password'
+    }
+    await createUser(servers[0].url, servers[0].accessToken, user.username, user.password, 10 * 1000 * 1000)
+    userAccessToken = await userLogin(servers[0], user)
+
+    await updateMyNotificationSettings(servers[0].url, userAccessToken, {
+      newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+    })
+
+    {
+      const socket = getUserNotificationSocket(servers[ 0 ].url, userAccessToken)
+      socket.on('new-notification', n => userNotifications.push(n))
+    }
+    {
+      const socket = getUserNotificationSocket(servers[ 0 ].url, servers[0].accessToken)
+      socket.on('new-notification', n => adminNotifications.push(n))
+    }
+  })
+
+  describe('New video from my subscription notification', function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+    })
+
+    it('Should not send notifications if the user does not follow the video publisher', async function () {
+      await uploadVideoByLocalAccount(servers, 1)
+
+      const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
+      expect(notification).to.be.undefined
+
+      expect(emails).to.have.lengthOf(0)
+      expect(userNotifications).to.have.lengthOf(0)
+    })
+
+    it('Should send a new video notification if the user follows the local video publisher', async function () {
+      await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
+
+      const videoNameId = 10
+      const videoName = 'local video ' + videoNameId
+
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should send a new video notification from a remote account', async function () {
+      this.timeout(50000) // Server 2 has transcoding enabled
+
+      await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
+
+      const videoNameId = 20
+      const videoName = 'remote video ' + videoNameId
+
+      const uuid = await uploadVideoByRemoteAccount(servers, videoNameId)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should send a new video notification on a scheduled publication', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 30
+      const videoName = 'local video ' + videoNameId
+
+      // In 2 seconds
+      let updateAt = new Date(new Date().getTime() + 2000)
+
+      const data = {
+        privacy: VideoPrivacy.PRIVATE,
+        scheduleUpdate: {
+          updateAt: updateAt.toISOString(),
+          privacy: VideoPrivacy.PUBLIC
+        }
+      }
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+      await wait(6000)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should send a new video notification on a remote scheduled publication', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 40
+      const videoName = 'remote video ' + videoNameId
+
+      // In 2 seconds
+      let updateAt = new Date(new Date().getTime() + 2000)
+
+      const data = {
+        privacy: VideoPrivacy.PRIVATE,
+        scheduleUpdate: {
+          updateAt: updateAt.toISOString(),
+          privacy: VideoPrivacy.PUBLIC
+        }
+      }
+      const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+      await wait(6000)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should not send a notification before the video is published', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 50
+      const videoName = 'local video ' + videoNameId
+
+      let updateAt = new Date(new Date().getTime() + 100000)
+
+      const data = {
+        privacy: VideoPrivacy.PRIVATE,
+        scheduleUpdate: {
+          updateAt: updateAt.toISOString(),
+          privacy: VideoPrivacy.PUBLIC
+        }
+      }
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+      await wait(6000)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+    })
+
+    it('Should send a new video notification when a video becomes public', async function () {
+      this.timeout(10000)
+
+      const videoNameId = 60
+      const videoName = 'local video ' + videoNameId
+
+      const data = { privacy: VideoPrivacy.PRIVATE }
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+
+      await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
+
+      await wait(500)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should send a new video notification when a remote video becomes public', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 70
+      const videoName = 'remote video ' + videoNameId
+
+      const data = { privacy: VideoPrivacy.PRIVATE }
+      const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+
+      await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
+
+      await waitJobs(servers)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+
+    it('Should not send a new video notification when a video becomes unlisted', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 80
+      const videoName = 'local video ' + videoNameId
+
+      const data = { privacy: VideoPrivacy.PRIVATE }
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+      await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
+
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+    })
+
+    it('Should not send a new video notification when a remote video becomes unlisted', async function () {
+      this.timeout(20000)
+
+      const videoNameId = 90
+      const videoName = 'remote video ' + videoNameId
+
+      const data = { privacy: VideoPrivacy.PRIVATE }
+      const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+      await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
+
+      await waitJobs(servers)
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+    })
+
+    it('Should send a new video notification after a video import', async function () {
+      this.timeout(30000)
+
+      const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken)
+      const channelId = resChannel.body.videoChannels[0].id
+      const videoName = 'local video 100'
+
+      const attributes = {
+        name: videoName,
+        channelId,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: getYoutubeVideoUrl()
+      }
+      const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
+      const uuid = res.body.video.uuid
+
+      await waitJobs(servers)
+
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+  })
+
+  describe('Comment on my video notifications', function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+    })
+
+    it('Should not send a new comment notification after a comment on another video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
+    })
+
+    it('Should not send a new comment notification if I comment my own video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, 'comment')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
+    })
+
+    it('Should send a new comment notification after a local comment on my video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
+    })
+
+    it('Should send a new comment notification after a remote comment on my video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      await waitJobs(servers)
+
+      const resComment = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
+      const commentId = resComment.body.comment.id
+
+      await waitJobs(servers)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
+    })
+
+    it('Should send a new comment notification after a local reply on my video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+
+      const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+      const threadId = resThread.body.comment.id
+
+      const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'reply')
+      const commentId = resComment.body.comment.id
+
+      await wait(500)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
+    })
+
+    it('Should send a new comment notification after a remote reply on my video', async function () {
+      this.timeout(10000)
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+      const uuid = resVideo.body.video.uuid
+      await waitJobs(servers)
+
+      const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
+      const threadId = resThread.body.comment.id
+
+      const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, 'reply')
+      const commentId = resComment.body.comment.id
+
+      await waitJobs(servers)
+      await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
+    })
+  })
+
+  describe('Video abuse for moderators notification' , function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: adminNotifications,
+        token: servers[0].accessToken
+      }
+    })
+
+    it('Should send a notification to moderators on local video abuse', async function () {
+      this.timeout(10000)
+
+      const videoName = 'local video 110'
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+      const uuid = resVideo.body.video.uuid
+
+      await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason')
+
+      await waitJobs(servers)
+      await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
+    })
+
+    it('Should send a notification to moderators on remote video abuse', async function () {
+      this.timeout(10000)
+
+      const videoName = 'remote video 120'
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+      const uuid = resVideo.body.video.uuid
+
+      await waitJobs(servers)
+
+      await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason')
+
+      await waitJobs(servers)
+      await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
+    })
+  })
+
+  describe('Video blacklist on my video', function () {
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+    })
+
+    it('Should send a notification to video owner on blacklist', async function () {
+      this.timeout(10000)
+
+      const videoName = 'local video 130'
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+      const uuid = resVideo.body.video.uuid
+
+      await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
+
+      await waitJobs(servers)
+      await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'blacklist')
+    })
+
+    it('Should send a notification to video owner on unblacklist', async function () {
+      this.timeout(10000)
+
+      const videoName = 'local video 130'
+
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+      const uuid = resVideo.body.video.uuid
+
+      await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
+
+      await waitJobs(servers)
+      await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, uuid)
+      await waitJobs(servers)
+
+      await wait(500)
+      await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'unblacklist')
+    })
+  })
+
+  describe('Mark as read', function () {
+    it('Should mark as read some notifications', async function () {
+      const res = await getUserNotifications(servers[0].url, userAccessToken, 2, 3)
+      const ids = res.body.data.map(n => n.id)
+
+      await markAsReadNotifications(servers[0].url, userAccessToken, ids)
+    })
+
+    it('Should have the notifications marked as read', async function () {
+      const res = await getUserNotifications(servers[0].url, userAccessToken, 0, 10)
+
+      const notifications = res.body.data as UserNotification[]
+      expect(notifications[0].read).to.be.false
+      expect(notifications[1].read).to.be.false
+      expect(notifications[2].read).to.be.true
+      expect(notifications[3].read).to.be.true
+      expect(notifications[4].read).to.be.true
+      expect(notifications[5].read).to.be.false
+    })
+  })
+
+  describe('Notification settings', function () {
+    const baseUpdateNotificationParams = {
+      newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+      blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+    }
+    let baseParams: CheckerBaseParams
+
+    before(() => {
+      baseParams = {
+        server: servers[0],
+        emails,
+        socketNotifications: userNotifications,
+        token: userAccessToken
+      }
+    })
+
+    it('Should not have notifications', async function () {
+      await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+        newVideoFromSubscription: UserNotificationSettingValue.NONE
+      }))
+
+      {
+        const res = await getMyUserInformation(servers[0].url, userAccessToken)
+        const info = res.body as User
+        expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE)
+      }
+
+      const videoNameId = 42
+      const videoName = 'local video ' + videoNameId
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+      const check = { web: true, mail: true }
+      await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+    })
+
+    it('Should only have web notifications', async function () {
+      await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+        newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION
+      }))
+
+      {
+        const res = await getMyUserInformation(servers[0].url, userAccessToken)
+        const info = res.body as User
+        expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION)
+      }
+
+      const videoNameId = 52
+      const videoName = 'local video ' + videoNameId
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+      {
+        const check = { mail: true, web: false }
+        await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+      }
+
+      {
+        const check = { mail: false, web: true }
+        await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
+      }
+    })
+
+    it('Should only have mail notifications', async function () {
+      await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+        newVideoFromSubscription: UserNotificationSettingValue.EMAIL
+      }))
+
+      {
+        const res = await getMyUserInformation(servers[0].url, userAccessToken)
+        const info = res.body as User
+        expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL)
+      }
+
+      const videoNameId = 62
+      const videoName = 'local video ' + videoNameId
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+      {
+        const check = { mail: false, web: true }
+        await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+      }
+
+      {
+        const check = { mail: true, web: false }
+        await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
+      }
+    })
+
+    it('Should have email and web notifications', async function () {
+      await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+        newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+      }))
+
+      {
+        const res = await getMyUserInformation(servers[0].url, userAccessToken)
+        const info = res.body as User
+        expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL)
+      }
+
+      const videoNameId = 72
+      const videoName = 'local video ' + videoNameId
+      const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+      await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+    })
+  })
+
+  after(async function () {
+    killallServers(servers)
+  })
+})
index 7114741e00ae8bd114ca58c5e12f0c684581089a..cd07cf320021bda49f747f5f18821a96d0d689b4 100644 (file)
@@ -1,6 +1,8 @@
 export * from './user.model'
 export * from './user-create.model'
 export * from './user-login.model'
+export * from './user-notification.model'
+export * from './user-notification-setting.model'
 export * from './user-refresh-token.model'
 export * from './user-update.model'
 export * 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 (file)
index 0000000..7cecd70
--- /dev/null
@@ -0,0 +1,13 @@
+export enum UserNotificationSettingValue {
+  NONE = 1,
+  WEB_NOTIFICATION = 2,
+  EMAIL = 3,
+  WEB_NOTIFICATION_AND_EMAIL = 4
+}
+
+export interface UserNotificationSetting {
+  newVideoFromSubscription: UserNotificationSettingValue
+  newCommentOnMyVideo: UserNotificationSettingValue
+  videoAbuseAsModerator: UserNotificationSettingValue
+  blacklistOnMyVideo: UserNotificationSettingValue
+}
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
new file mode 100644 (file)
index 0000000..39beb23
--- /dev/null
@@ -0,0 +1,47 @@
+export enum UserNotificationType {
+  NEW_VIDEO_FROM_SUBSCRIPTION = 1,
+  NEW_COMMENT_ON_MY_VIDEO = 2,
+  NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
+  BLACKLIST_ON_MY_VIDEO = 4,
+  UNBLACKLIST_ON_MY_VIDEO = 5
+}
+
+interface VideoInfo {
+  id: number
+  uuid: string
+  name: string
+}
+
+export interface UserNotification {
+  id: number
+  type: UserNotificationType
+  read: boolean
+
+  video?: VideoInfo & {
+    channel: {
+      id: number
+      displayName: string
+    }
+  }
+
+  comment?: {
+    id: number
+    account: {
+      id: number
+      displayName: string
+    }
+  }
+
+  videoAbuse?: {
+    id: number
+    video: VideoInfo
+  }
+
+  videoBlacklist?: {
+    id: number
+    video: VideoInfo
+  }
+
+  createdAt: string
+  updatedAt: string
+}
index 2aabff49488f8f057f30f7ac3874a30ebead81c2..af783d38907e085cce43989e11caf7ac06065ae9 100644 (file)
@@ -2,6 +2,7 @@ import { Account } from '../actors'
 import { VideoChannel } from '../videos/channel/video-channel.model'
 import { UserRole } from './user-role'
 import { NSFWPolicyType } from '../videos/nsfw-policy.type'
+import { UserNotificationSetting } from './user-notification-setting.model'
 
 export interface User {
   id: number
@@ -19,6 +20,7 @@ export interface User {
   videoQuotaDaily: number
   createdAt: Date
   account: Account
+  notificationSettings?: UserNotificationSetting
   videoChannels?: VideoChannel[]
 
   blocked: boolean
index f4623f8967dddb0b0673b656246c9c3e8f88e112..6218c0b6628d4015ae51f021a13db38a384c8dd3 100644 (file)
@@ -35,10 +35,10 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
   else servers = serversArg as ServerInfo[]
 
   const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
-  const tasks: Promise<any>[] = []
-  let pendingRequests: boolean
+  let pendingRequests = false
 
-  do {
+  function tasksBuilder () {
+    const tasks: Promise<any>[] = []
     pendingRequests = false
 
     // Check if each server has pending request
@@ -54,13 +54,16 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
       }
     }
 
-    await Promise.all(tasks)
+    return tasks
+  }
+
+  do {
+    await Promise.all(tasksBuilder())
 
     // Retry, in case of new jobs were created
     if (pendingRequests === false) {
       await wait(2000)
-
-      await Promise.all(tasks)
+      await Promise.all(tasksBuilder())
     }
 
     if (pendingRequests) {
diff --git a/shared/utils/socket/socket-io.ts b/shared/utils/socket/socket-io.ts
new file mode 100644 (file)
index 0000000..854ab71
--- /dev/null
@@ -0,0 +1,13 @@
+import * as io from 'socket.io-client'
+
+function getUserNotificationSocket (serverUrl: string, accessToken: string) {
+  return io(serverUrl + '/user-notifications', {
+    query: { accessToken }
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getUserNotificationSocket
+}
diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts
new file mode 100644 (file)
index 0000000..dbe8755
--- /dev/null
@@ -0,0 +1,232 @@
+/* tslint:disable:no-unused-expression */
+
+import { makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
+import { UserNotification, UserNotificationSetting, UserNotificationType } from '../../models/users'
+import { ServerInfo } from '..'
+import { expect } from 'chai'
+
+function updateMyNotificationSettings (url: string, token: string, settings: UserNotificationSetting, statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/notification-settings'
+
+  return makePutBodyRequest({
+    url,
+    path,
+    token,
+    fields: settings,
+    statusCodeExpected
+  })
+}
+
+function getUserNotifications (url: string, token: string, start: number, count: number, sort = '-createdAt', statusCodeExpected = 200) {
+  const path = '/api/v1/users/me/notifications'
+
+  return makeGetRequest({
+    url,
+    path,
+    token,
+    query: {
+      start,
+      count,
+      sort
+    },
+    statusCodeExpected
+  })
+}
+
+function markAsReadNotifications (url: string, token: string, ids: number[], statusCodeExpected = 204) {
+  const path = '/api/v1/users/me/notifications/read'
+
+  return makePostBodyRequest({
+    url,
+    path,
+    token,
+    fields: { ids },
+    statusCodeExpected
+  })
+}
+
+async function getLastNotification (serverUrl: string, accessToken: string) {
+  const res = await getUserNotifications(serverUrl, accessToken, 0, 1, '-createdAt')
+
+  if (res.body.total === 0) return undefined
+
+  return res.body.data[0] as UserNotification
+}
+
+type CheckerBaseParams = {
+  server: ServerInfo
+  emails: object[]
+  socketNotifications: UserNotification[]
+  token: string,
+  check?: { web: boolean, mail: boolean }
+}
+
+type CheckerType = 'presence' | 'absence'
+
+async function checkNotification (
+  base: CheckerBaseParams,
+  lastNotificationChecker: (notification: UserNotification) => void,
+  socketNotificationFinder: (notification: UserNotification) => boolean,
+  emailNotificationFinder: (email: object) => boolean,
+  checkType: 'presence' | 'absence'
+) {
+  const check = base.check || { web: true, mail: true }
+
+  if (check.web) {
+    const notification = await getLastNotification(base.server.url, base.token)
+    lastNotificationChecker(notification)
+
+    const socketNotification = base.socketNotifications.find(n => socketNotificationFinder(n))
+
+    if (checkType === 'presence') expect(socketNotification, 'The socket notification is absent.').to.not.be.undefined
+    else expect(socketNotification, 'The socket notification is present.').to.be.undefined
+  }
+
+  if (check.mail) {
+    // Last email
+    const email = base.emails
+                      .slice()
+                      .reverse()
+                      .find(e => emailNotificationFinder(e))
+
+    if (checkType === 'presence') expect(email, 'The email is present.').to.not.be.undefined
+    else expect(email, 'The email is absent.').to.be.undefined
+  }
+}
+
+async function checkNewVideoFromSubscription (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
+
+  function lastNotificationChecker (notification: UserNotification) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+      expect(notification.video.name).to.equal(videoName)
+    } else {
+      expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
+    }
+  }
+
+  function socketFinder (notification: UserNotification) {
+    return notification.type === notificationType && notification.video.name === videoName
+  }
+
+  function emailFinder (email: object) {
+    return email[ 'text' ].indexOf(videoUUID) !== -1
+  }
+
+  await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+}
+
+let lastEmailCount = 0
+async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
+
+  function lastNotificationChecker (notification: UserNotification) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+      expect(notification.comment.id).to.equal(commentId)
+      expect(notification.comment.account.displayName).to.equal('root')
+    } else {
+      expect(notification).to.satisfy((n: UserNotification) => {
+        return n === undefined || n.comment === undefined || n.comment.id !== commentId
+      })
+    }
+  }
+
+  function socketFinder (notification: UserNotification) {
+    return notification.type === notificationType &&
+      notification.comment.id === commentId &&
+      notification.comment.account.displayName === 'root'
+  }
+
+  const commentUrl = `http://localhost:9001/videos/watch/${uuid};threadId=${threadId}`
+  function emailFinder (email: object) {
+    return email[ 'text' ].indexOf(commentUrl) !== -1
+  }
+
+  await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+
+  if (type === 'presence') {
+    // We cannot detect email duplicates, so check we received another email
+    expect(base.emails).to.have.length.above(lastEmailCount)
+    lastEmailCount = base.emails.length
+  }
+}
+
+async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS
+
+  function lastNotificationChecker (notification: UserNotification) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+      expect(notification.videoAbuse.video.uuid).to.equal(videoUUID)
+      expect(notification.videoAbuse.video.name).to.equal(videoName)
+    } else {
+      expect(notification).to.satisfy((n: UserNotification) => {
+        return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID
+      })
+    }
+  }
+
+  function socketFinder (notification: UserNotification) {
+    return notification.type === notificationType && notification.videoAbuse.video.uuid === videoUUID
+  }
+
+  function emailFinder (email: object) {
+    const text = email[ 'text' ]
+    return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
+  }
+
+  await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+}
+
+async function checkNewBlacklistOnMyVideo (
+  base: CheckerBaseParams,
+  videoUUID: string,
+  videoName: string,
+  blacklistType: 'blacklist' | 'unblacklist'
+) {
+  const notificationType = blacklistType === 'blacklist'
+    ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
+    : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO
+
+  function lastNotificationChecker (notification: UserNotification) {
+    expect(notification).to.not.be.undefined
+    expect(notification.type).to.equal(notificationType)
+
+    const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video
+
+    expect(video.uuid).to.equal(videoUUID)
+    expect(video.name).to.equal(videoName)
+  }
+
+  function socketFinder (notification: UserNotification) {
+    return notification.type === notificationType && (notification.video || notification.videoBlacklist.video).uuid === videoUUID
+  }
+
+  function emailFinder (email: object) {
+    const text = email[ 'text' ]
+    return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1
+  }
+
+  await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, 'presence')
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  CheckerBaseParams,
+  CheckerType,
+  checkNotification,
+  checkNewVideoFromSubscription,
+  checkNewCommentOnMyVideo,
+  checkNewBlacklistOnMyVideo,
+  updateMyNotificationSettings,
+  checkNewVideoAbuseForModerators,
+  getUserNotifications,
+  markAsReadNotifications,
+  getLastNotification
+}
index 6eb6c9a59c4c1be53b60ff81ef75f7bd10705013..1e759af1b6a18587d9f12d91368217dc1c77de8c 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   dependencies:
     "@types/node" "*"
 
+"@types/socket.io@^2.1.2":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@types/socket.io/-/socket.io-2.1.2.tgz#7165c2587cc3b86b44aa78e2a0060140551de211"
+  integrity sha512-Ind+4qMNfQ62llyB4IMs1D8znMEBsMKohZBPqfBUIXqLQ9bdtWIbNTBWwtdcBWJKnokMZGcmWOOKslatni5vtA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/superagent@*":
   version "3.8.4"
   resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.4.tgz#24a5973c7d1a9c024b4bbda742a79267c33fb86a"
@@ -423,7 +430,7 @@ accepts@~1.2.12:
     mime-types "~2.1.6"
     negotiator "0.5.3"
 
-accepts@~1.3.5:
+accepts@~1.3.4, accepts@~1.3.5:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
   integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I=
@@ -652,6 +659,11 @@ arraybuffer.slice@0.0.6:
   resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca"
   integrity sha1-8zshWfBTKj8xB6JywMz70a0peco=
 
+arraybuffer.slice@~0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
 arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
@@ -977,6 +989,11 @@ blob@0.0.4:
   resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921"
   integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=
 
+blob@0.0.5:
+  version "0.0.5"
+  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
 block-stream2@^1.0.0:
   version "1.1.0"
   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.
   dependencies:
     ms "2.0.0"
 
-debug@3.1.0:
+debug@3.1.0, debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
@@ -2016,6 +2033,13 @@ debug@^4.0.1:
   dependencies:
     ms "^2.1.1"
 
+debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
 debuglog@^1.0.0, debuglog@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
@@ -2367,6 +2391,23 @@ engine.io-client@1.8.3:
     xmlhttprequest-ssl "1.5.3"
     yeast "0.1.2"
 
+engine.io-client@~3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03"
+  integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ==
+  dependencies:
+    component-emitter "1.2.1"
+    component-inherit "0.0.3"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.1"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    ws "~6.1.0"
+    xmlhttprequest-ssl "~1.5.4"
+    yeast "0.1.2"
+
 engine.io-parser@1.3.2:
   version "1.3.2"
   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:
     has-binary "0.1.7"
     wtf-8 "1.0.0"
 
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
+  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
+  dependencies:
+    after "0.8.2"
+    arraybuffer.slice "~0.0.7"
+    base64-arraybuffer "0.1.5"
+    blob "0.0.5"
+    has-binary2 "~1.0.2"
+
 engine.io@1.8.3:
   version "1.8.3"
   resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4"
@@ -2391,6 +2443,18 @@ engine.io@1.8.3:
     engine.io-parser "1.3.2"
     ws "1.1.2"
 
+engine.io@~3.3.1:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
+  integrity sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "1.0.0"
+    cookie "0.3.1"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.0"
+    ws "~6.1.0"
+
 env-variable@0.0.x:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
@@ -3389,6 +3453,13 @@ has-ansi@^2.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
+has-binary2@~1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+  dependencies:
+    isarray "2.0.1"
+
 has-binary@0.1.7:
   version "0.1.7"
   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:
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
+isarray@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -7542,6 +7618,11 @@ socket.io-adapter@0.5.0:
     debug "2.3.3"
     socket.io-parser "2.3.1"
 
+socket.io-adapter@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
+  integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=
+
 socket.io-client@1.7.3:
   version "1.7.3"
   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:
     socket.io-parser "2.3.1"
     to-array "0.1.4"
 
+socket.io-client@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
+  integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
+  dependencies:
+    backo2 "1.0.2"
+    base64-arraybuffer "0.1.5"
+    component-bind "1.0.0"
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    engine.io-client "~3.3.1"
+    has-binary2 "~1.0.2"
+    has-cors "1.1.0"
+    indexof "0.0.1"
+    object-component "0.0.3"
+    parseqs "0.0.5"
+    parseuri "0.0.5"
+    socket.io-parser "~3.3.0"
+    to-array "0.1.4"
+
 socket.io-parser@2.3.1:
   version "2.3.1"
   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:
     isarray "0.0.1"
     json3 "3.3.2"
 
+socket.io-parser@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  dependencies:
+    component-emitter "1.2.1"
+    debug "~3.1.0"
+    isarray "2.0.1"
+
 socket.io@1.7.3:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b"
@@ -7582,6 +7692,18 @@ socket.io@1.7.3:
     socket.io-client "1.7.3"
     socket.io-parser "2.3.1"
 
+socket.io@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
+  integrity sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==
+  dependencies:
+    debug "~4.1.0"
+    engine.io "~3.3.1"
+    has-binary2 "~1.0.2"
+    socket.io-adapter "~1.1.0"
+    socket.io-client "2.2.0"
+    socket.io-parser "~3.3.0"
+
 socks-proxy-agent@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz#2eae7cf8e2a82d34565761539a7f9718c5617659"
@@ -8954,7 +9076,7 @@ ws@1.1.2:
     options ">=0.0.5"
     ultron "1.0.x"
 
-ws@^6.0.0:
+ws@^6.0.0, ws@~6.1.0:
   version "6.1.2"
   resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
   integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==
@@ -9028,6 +9150,11 @@ xmlhttprequest-ssl@1.5.3:
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"
   integrity sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=
 
+xmlhttprequest-ssl@~1.5.4:
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
 "xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"