From cef534ed53e4518fe0acf581bfe880788d42fc36 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 26 Dec 2018 10:36:24 +0100 Subject: [PATCH] Add user notification base code --- package.json | 2 + server.ts | 11 +- server/controllers/api/users/index.ts | 2 + .../controllers/api/users/my-notifications.ts | 84 +++ server/controllers/api/videos/abuse.ts | 3 + server/controllers/api/videos/blacklist.ts | 14 +- server/controllers/api/videos/comment.ts | 3 + server/controllers/api/videos/index.ts | 10 +- server/controllers/feeds.ts | 2 +- server/controllers/tracker.ts | 4 +- server/helpers/custom-validators/misc.ts | 5 + .../custom-validators/user-notifications.ts | 19 + server/initializers/constants.ts | 4 +- server/initializers/database.ts | 6 +- .../activitypub/process/process-announce.ts | 8 +- .../lib/activitypub/process/process-create.ts | 14 +- server/lib/activitypub/video-comments.ts | 4 +- server/lib/activitypub/videos.ts | 15 +- server/lib/client-html.ts | 4 +- server/lib/emailer.ts | 176 +++-- server/lib/job-queue/handlers/video-file.ts | 5 +- server/lib/job-queue/handlers/video-import.ts | 2 + server/lib/notifier.ts | 235 +++++++ server/lib/oauth-model.ts | 3 +- server/lib/peertube-socket.ts | 52 ++ .../lib/schedulers/update-videos-scheduler.ts | 5 + server/lib/user.ts | 16 + server/middlewares/oauth.ts | 22 + server/middlewares/validators/sort.ts | 5 +- server/middlewares/validators/user-history.ts | 8 +- .../validators/user-notifications.ts | 46 ++ .../account/user-notification-setting.ts | 100 +++ server/models/account/user-notification.ts | 256 +++++++ server/models/account/user.ts | 101 ++- server/models/activitypub/actor-follow.ts | 14 +- server/models/activitypub/actor.ts | 1 + server/models/video/video-abuse.ts | 5 - server/models/video/video-blacklist.ts | 10 - server/models/video/video-comment.ts | 4 + server/models/video/video.ts | 4 + server/tests/api/check-params/index.ts | 1 + .../api/check-params/user-notifications.ts | 249 +++++++ server/tests/api/users/index.ts | 1 + server/tests/api/users/user-notifications.ts | 628 ++++++++++++++++++ shared/models/users/index.ts | 2 + .../users/user-notification-setting.model.ts | 13 + .../models/users/user-notification.model.ts | 47 ++ shared/models/users/user.model.ts | 2 + shared/utils/server/jobs.ts | 15 +- shared/utils/socket/socket-io.ts | 13 + shared/utils/users/user-notifications.ts | 232 +++++++ yarn.lock | 133 +++- 52 files changed, 2479 insertions(+), 141 deletions(-) create mode 100644 server/controllers/api/users/my-notifications.ts create mode 100644 server/helpers/custom-validators/user-notifications.ts create mode 100644 server/lib/notifier.ts create mode 100644 server/lib/peertube-socket.ts create mode 100644 server/middlewares/validators/user-notifications.ts create mode 100644 server/models/account/user-notification-setting.ts create mode 100644 server/models/account/user-notification.ts create mode 100644 server/tests/api/check-params/user-notifications.ts create mode 100644 server/tests/api/users/user-notifications.ts create mode 100644 shared/models/users/user-notification-setting.model.ts create mode 100644 shared/models/users/user-notification.model.ts create mode 100644 shared/utils/socket/socket-io.ts create mode 100644 shared/utils/users/user-notifications.ts diff --git a/package.json b/package.json index 3983f5f2c..ea3f88e24 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "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", @@ -189,6 +190,7 @@ "@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", diff --git a/server.ts b/server.ts index 868a03ba4..b50151859 100644 --- a/server.ts +++ b/server.ts @@ -28,7 +28,7 @@ import { checkMissedConfig, checkFFmpeg } from './server/initializers/checker-be // 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) diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index bc24792a2..98be46ea2 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -39,6 +39,7 @@ import { meRouter } from './me' 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 index 000000000..cef1d237c --- /dev/null +++ b/server/controllers/api/users/my-notifications.ts @@ -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() +} diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts index d0c81804b..fe0a95cd5 100644 --- a/server/controllers/api/videos/abuse.ts +++ b/server/controllers/api/videos/abuse.ts @@ -22,6 +22,7 @@ import { VideoModel } from '../../../models/video/video' 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 diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts index 7f803c8e9..9ef08812b 100644 --- a/server/controllers/api/videos/blacklist.ts +++ b/server/controllers/api/videos/blacklist.ts @@ -16,6 +16,8 @@ import { } 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() diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index 3875c8f79..70c1148ba 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -26,6 +26,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment' 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() diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 00a1302d1..94ed08fed 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -7,7 +7,8 @@ import { logger } from '../../../helpers/logger' 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()), diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index ccb9b6029..960085af1 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -56,7 +56,7 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res // 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()}`, diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts index 9bc7586d1..53f1653b5 100644 --- a/server/controllers/tracker.ts +++ b/server/controllers/tracker.ts @@ -59,7 +59,7 @@ const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer) 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 } // --------------------------------------------------------------------------- diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index 6d10a65a8..a093e3e1b 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts @@ -9,6 +9,10 @@ function isArray (value: any) { 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 index 000000000..4fb5d922d --- /dev/null +++ b/server/helpers/custom-validators/user-notifications.ts @@ -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 +} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index c3df2383a..fcfaf71a0 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -50,7 +50,9 @@ const SORTABLE_COLUMNS = { VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], ACCOUNTS_BLOCKLIST: [ 'createdAt' ], - SERVERS_BLOCKLIST: [ 'createdAt' ] + SERVERS_BLOCKLIST: [ 'createdAt' ], + + USER_NOTIFICATIONS: [ 'createdAt' ] } const OAUTH_LIFETIME = { diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 40cd659ab..84ad2079b 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -31,6 +31,8 @@ import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' 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 diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts index cc88b5423..23310b41e 100644 --- a/server/lib/activitypub/process/process-announce.ts +++ b/server/lib/activitypub/process/process-announce.ts @@ -5,6 +5,8 @@ import { ActorModel } from '../../../models/activitypub/actor' 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) } diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index df05ee452..2e04ee843 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -13,6 +13,7 @@ import { forwardVideoRelatedActivity } from '../send/utils' 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) } diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts index 5868e7297..e87301fe7 100644 --- a/server/lib/activitypub/video-comments.ts +++ b/server/lib/activitypub/video-comments.ts @@ -70,7 +70,7 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) { 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 } } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 379c2a0d7..5794988a5 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -29,6 +29,7 @@ import { addVideoShares, shareVideoByServerAndChannel } from './share' 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) diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 2db3f8a34..1875ec1fc 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -115,8 +115,8 @@ export class ClientHtml { } 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) diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index 074d4ad44..d766e655b 100644 --- a/server/lib/emailer.ts +++ b/server/lib/emailer.ts @@ -1,5 +1,4 @@ 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,6 +81,106 @@ class Emailer { } } + addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) { + const channelName = video.VideoChannel.getDisplayName() + const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() + + const text = `Hi dear user,\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, + subject: channelName + ' just published a new video', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + 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, + subject: 'New comment on your video ' + video.name, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) { + const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath() + + const text = `Hi,\n\n` + + `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: '[PeerTube] Received a video abuse', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) { + const videoName = videoBlacklist.Video.name + const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() + + 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 + + '\n\n' + + 'Cheers,\n' + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: `[PeerTube] Video ${videoName} blacklisted`, + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + async addVideoUnblacklistNotification (to: string[], video: VideoModel) { + const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() + + const text = 'Hi,\n\n' + + `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + + '\n\n' + + 'Cheers,\n' + + `PeerTube.` + + const emailPayload: EmailPayload = { + to, + subject: `[PeerTube] Video ${video.name} unblacklisted`, + text + } + + 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}! ` + @@ -113,76 +215,6 @@ class Emailer { 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.') - - const text = `Hi,\n\n` + - `Your instance received an abuse for the following video ${video.url}\n\n` + - `Cheers,\n` + - `PeerTube.` - - const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES) - const emailPayload: EmailPayload = { - to, - subject: '[PeerTube] Received a video abuse', - text - } - - 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 - - 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 text = 'Hi,\n\n' + - blockedString + - '\n\n' + - 'Cheers,\n' + - `PeerTube.` - - const to = user.email - const emailPayload: EmailPayload = { - to: [ to ], - subject: `[PeerTube] Video ${video.name} 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) - - const text = 'Hi,\n\n' + - `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + - '\n\n' + - 'Cheers,\n' + - `PeerTube.` - - const to = user.email - const emailPayload: EmailPayload = { - to: [ to ], - subject: `[PeerTube] Video ${video.name} unblacklisted`, - 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.') } diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts index 3dca2937f..959cc04fa 100644 --- a/server/lib/job-queue/handlers/video-file.ts +++ b/server/lib/job-queue/handlers/video-file.ts @@ -9,6 +9,7 @@ import { sequelizeTypescript } from '../../../initializers' 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) }) } diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 63aacff98..82edb8d5c 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -15,6 +15,7 @@ import { VideoModel } from '../../../models/video/video' 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, 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 index 000000000..a21b50b2d --- /dev/null +++ b/server/lib/notifier.ts @@ -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, + emailSender: (emails: string[]) => Promise | Bluebird, + 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 +} diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts index 5cbe60b82..2cd2ae97c 100644 --- a/server/lib/oauth-model.ts +++ b/server/lib/oauth-model.ts @@ -1,3 +1,4 @@ +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 index 000000000..eb84ecd4b --- /dev/null +++ b/server/lib/peertube-socket.ts @@ -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 +} diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts index 21f071f9e..b7fb029f1 100644 --- a/server/lib/schedulers/update-videos-scheduler.ts +++ b/server/lib/schedulers/update-videos-scheduler.ts @@ -5,6 +5,7 @@ import { retryTransactionWrapper } from '../../helpers/database-utils' 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 }) diff --git a/server/lib/user.ts b/server/lib/user.ts index 29d6d087d..72127819c 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -9,6 +9,8 @@ import { createVideoChannel } from './video-channel' 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 }) +} diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts index 8c1df2c3e..1d193d467 100644 --- a/server/middlewares/oauth.ts +++ b/server/middlewares/oauth.ts @@ -3,6 +3,8 @@ import * as OAuthServer from 'express-oauth-server' 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 diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index 4c0577d8f..5ceda845f 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -18,6 +18,7 @@ const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOW 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 } diff --git a/server/middlewares/validators/user-history.ts b/server/middlewares/validators/user-history.ts index 3c8971ea1..418313d09 100644 --- a/server/middlewares/validators/user-history.ts +++ b/server/middlewares/validators/user-history.ts @@ -1,13 +1,9 @@ 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 index 000000000..8202f307e --- /dev/null +++ b/server/middlewares/validators/user-notifications.ts @@ -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 index 000000000..bc24b1e33 --- /dev/null +++ b/server/models/account/user-notification-setting.ts @@ -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 { + + @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 index 000000000..e22f0d57f --- /dev/null +++ b/server/models/account/user-notification.ts @@ -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 { + + @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() + } + } +} diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 180ced810..55ec14d05 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -32,8 +32,8 @@ import { 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 { }) 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 { }) } - 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 { } } - 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 { 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 { blocked: this.blocked, blockedReason: this.blockedReason, account: this.Account.toFormattedJSON(), + notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined, videoChannels: [], videoQuotaUsed: videoQuotaUsed !== undefined ? parseInt(videoQuotaUsed, 10) diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index 994f791de..796e07a42 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts @@ -307,7 +307,7 @@ export class ActorFollowModel extends Model { }) } - 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 { as: 'ActorFollowing', required: true, where: { - id + id: actorId } } ] @@ -350,7 +350,7 @@ export class ActorFollowModel extends Model { }) } - 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 { limit: count, order: getSort(sort), where: { - actorId: id + actorId: actorId }, include: [ { @@ -451,9 +451,9 @@ export class ActorFollowModel extends Model { 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 = { diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 12b83916e..dda57a8ba 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -219,6 +219,7 @@ export class ActorModel extends Model { name: 'actorId', allowNull: false }, + as: 'ActorFollowings', onDelete: 'cascade' }) ActorFollowing: ActorFollowModel[] diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts index dbb88ca45..4c9e2d05e 100644 --- a/server/models/video/video-abuse.ts +++ b/server/models/video/video-abuse.ts @@ -86,11 +86,6 @@ export class VideoAbuseModel extends Model { }) Video: VideoModel - @AfterCreate - static sendEmailNotification (instance: VideoAbuseModel) { - return Emailer.Instance.addVideoAbuseReportJob(instance.videoId) - } - static loadByIdAndVideoId (id: number, videoId: number) { const query = { where: { diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts index 67f7cd487..23e992685 100644 --- a/server/models/video/video-blacklist.ts +++ b/server/models/video/video-blacklist.ts @@ -53,16 +53,6 @@ export class VideoBlacklistModel extends Model { }) 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, diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index dd6d08139..d8fc2a564 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -448,6 +448,10 @@ export class VideoCommentModel extends Model { } } + getCommentStaticPath () { + return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId() + } + getThreadId (): number { return this.originCommentId || this.id } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index bcf327f32..fc200e5d1 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1527,6 +1527,10 @@ export class VideoModel extends Model { videoFile.infoHash = parsedTorrent.infoHash } + getWatchStaticPath () { + return '/videos/watch/' + this.uuid + } + getEmbedStaticPath () { return '/videos/embed/' + this.uuid } diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 877ceb0a7..7a181d1d6 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -7,6 +7,7 @@ import './jobs' 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 index 000000000..3ae36ddb3 --- /dev/null +++ b/server/tests/api/check-params/user-notifications.ts @@ -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() + } + }) +}) diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index ff433315d..63e6e827a 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts @@ -1,5 +1,6 @@ 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 index 000000000..ea35e6390 --- /dev/null +++ b/server/tests/api/users/user-notifications.ts @@ -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) + }) +}) diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts index 7114741e0..cd07cf320 100644 --- a/shared/models/users/index.ts +++ b/shared/models/users/index.ts @@ -1,6 +1,8 @@ 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 index 000000000..7cecd70a2 --- /dev/null +++ b/shared/models/users/user-notification-setting.model.ts @@ -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 index 000000000..39beb2350 --- /dev/null +++ b/shared/models/users/user-notification.model.ts @@ -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 +} diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts index 2aabff494..af783d389 100644 --- a/shared/models/users/user.model.ts +++ b/shared/models/users/user.model.ts @@ -2,6 +2,7 @@ import { Account } from '../actors' 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 diff --git a/shared/utils/server/jobs.ts b/shared/utils/server/jobs.ts index f4623f896..6218c0b66 100644 --- a/shared/utils/server/jobs.ts +++ b/shared/utils/server/jobs.ts @@ -35,10 +35,10 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) { else servers = serversArg as ServerInfo[] const states: JobState[] = [ 'waiting', 'active', 'delayed' ] - const tasks: Promise[] = [] - let pendingRequests: boolean + let pendingRequests = false - do { + function tasksBuilder () { + const tasks: Promise[] = [] 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 index 000000000..854ab71af --- /dev/null +++ b/shared/utils/socket/socket-io.ts @@ -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 index 000000000..dbe87559e --- /dev/null +++ b/shared/utils/users/user-notifications.ts @@ -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 +} diff --git a/yarn.lock b/yarn.lock index 6eb6c9a59..1e759af1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -346,6 +346,13 @@ 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"