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/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 ++ 13 files changed, 425 insertions(+), 54 deletions(-) create mode 100644 server/lib/notifier.ts create mode 100644 server/lib/peertube-socket.ts (limited to 'server/lib') 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 }) +} -- cgit v1.2.3