From cef534ed53e4518fe0acf581bfe880788d42fc36 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 26 Dec 2018 10:36:24 +0100 Subject: Add user notification base code --- server/controllers/api/users/index.ts | 2 + server/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 +- server/lib/activitypub/process/process-announce.ts | 8 +- server/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 | 116 ++-- 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 ++ server/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 +- .../middlewares/validators/user-notifications.ts | 46 ++ server/models/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 + .../tests/api/check-params/user-notifications.ts | 249 ++++++++ server/tests/api/users/index.ts | 1 + server/tests/api/users/user-notifications.ts | 628 +++++++++++++++++++++ 42 files changed, 1992 insertions(+), 98 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 (limited to 'server') 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,50 +81,57 @@ class Emailer { } } - addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { + addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) { + const channelName = video.VideoChannel.getDisplayName() + const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() + const text = `Hi dear user,\n\n` + - `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + - `Please follow this link to reset it: ${resetPasswordUrl}\n\n` + - `If you are not the person who initiated this request, please ignore this email.\n\n` + + `Your subscription ${channelName} just published a new video: ${video.name}` + + `\n\n` + + `You can view it on ${videoUrl} ` + + `\n\n` + `Cheers,\n` + `PeerTube.` const emailPayload: EmailPayload = { - to: [ to ], - subject: 'Reset your PeerTube password', + to, + subject: channelName + ' just published a new video', text } return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } - addVerifyEmailJob (to: string, verifyEmailUrl: string) { - const text = `Welcome to PeerTube,\n\n` + - `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` + - `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` + - `If you are not the person who initiated this request, please ignore this email.\n\n` + + addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) { + const accountName = comment.Account.getDisplayName() + const video = comment.Video + const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath() + + const text = `Hi dear user,\n\n` + + `A new comment has been posted by ${accountName} on your video ${video.name}` + + `\n\n` + + `You can view it on ${commentUrl} ` + + `\n\n` + `Cheers,\n` + `PeerTube.` const emailPayload: EmailPayload = { - to: [ to ], - subject: 'Verify your PeerTube email', + to, + subject: 'New comment on your video ' + video.name, text } return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } - async addVideoAbuseReportJob (videoId: number) { - const video = await VideoModel.load(videoId) - if (!video) throw new Error('Unknown Video id during Abuse report.') + async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) { + const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath() const text = `Hi,\n\n` + - `Your instance received an abuse for the following video ${video.url}\n\n` + + `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` + `Cheers,\n` + `PeerTube.` - const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES) const emailPayload: EmailPayload = { to, subject: '[PeerTube] Received a video abuse', @@ -132,16 +141,12 @@ class Emailer { return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } - async addVideoBlacklistReportJob (videoId: number, reason?: string) { - const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) - if (!video) throw new Error('Unknown Video id during Blacklist report.') - // It's not our user - if (video.remote === true) return + async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) { + const videoName = videoBlacklist.Video.name + const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() - const user = await UserModel.loadById(video.VideoChannel.Account.userId) - - const reasonString = reason ? ` for the following reason: ${reason}` : '' - const blockedString = `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.` + const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : '' + const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.` const text = 'Hi,\n\n' + blockedString + @@ -149,33 +154,26 @@ class Emailer { 'Cheers,\n' + `PeerTube.` - const to = user.email const emailPayload: EmailPayload = { - to: [ to ], - subject: `[PeerTube] Video ${video.name} blacklisted`, + to, + subject: `[PeerTube] Video ${videoName} blacklisted`, text } return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } - async addVideoUnblacklistReportJob (videoId: number) { - const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) - if (!video) throw new Error('Unknown Video id during Blacklist report.') - // It's not our user - if (video.remote === true) return - - const user = await UserModel.loadById(video.VideoChannel.Account.userId) + async addVideoUnblacklistNotification (to: string[], video: VideoModel) { + const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() const text = 'Hi,\n\n' + - `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + + `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` + '\n\n' + 'Cheers,\n' + `PeerTube.` - const to = user.email const emailPayload: EmailPayload = { - to: [ to ], + to, subject: `[PeerTube] Video ${video.name} unblacklisted`, text } @@ -183,6 +181,40 @@ class Emailer { return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) } + addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { + const text = `Hi dear user,\n\n` + + `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + + `Please follow this link to reset it: ${resetPasswordUrl}\n\n` + + `If you are not the person who initiated this request, please ignore this email.\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to: [ to ], + subject: 'Reset your PeerTube password', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + addVerifyEmailJob (to: string, verifyEmailUrl: string) { + const text = `Welcome to PeerTube,\n\n` + + `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` + + `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` + + `If you are not the person who initiated this request, please ignore this email.\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to: [ to ], + subject: 'Verify your PeerTube email', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) { const reasonString = reason ? ` for the following reason: ${reason}` : '' const blockedWord = blocked ? 'blocked' : 'unblocked' @@ -205,7 +237,7 @@ class Emailer { } sendMail (to: string[], subject: string, text: string) { - if (!this.transporter) { + if (!this.enabled) { throw new Error('Cannot send mail because SMTP is not configured.') } 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) + }) +}) -- cgit v1.2.3