1
0
Fork 0

Add new follow, mention and user registered notifs

This commit is contained in:
Chocobozzz 2019-01-04 08:56:20 +01:00 committed by Chocobozzz
parent dc13348070
commit f7cc67b455
25 changed files with 899 additions and 57 deletions

View file

@ -40,6 +40,7 @@ import { deleteUserToken } from '../../../lib/oauth-model'
import { myBlocklistRouter } from './my-blocklist' import { myBlocklistRouter } from './my-blocklist'
import { myVideosHistoryRouter } from './my-history' import { myVideosHistoryRouter } from './my-history'
import { myNotificationsRouter } from './my-notifications' import { myNotificationsRouter } from './my-notifications'
import { Notifier } from '../../../lib/notifier'
const auditLogger = auditLoggerFactory('users') const auditLogger = auditLoggerFactory('users')
@ -213,6 +214,8 @@ async function registerUser (req: express.Request, res: express.Response) {
await sendVerifyUserEmail(user) await sendVerifyUserEmail(user)
} }
Notifier.Instance.notifyOnNewUserRegistration(user)
return res.type('json').status(204).end() return res.type('json').status(204).end()
} }

View file

@ -18,7 +18,7 @@ import {
markAsReadUserNotificationsValidator, markAsReadUserNotificationsValidator,
updateNotificationSettingsValidator updateNotificationSettingsValidator
} from '../../../middlewares/validators/user-notifications' } from '../../../middlewares/validators/user-notifications'
import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users' import { UserNotificationSetting } from '../../../../shared/models/users'
import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
const myNotificationsRouter = express.Router() const myNotificationsRouter = express.Router()
@ -53,7 +53,7 @@ export {
async function updateNotificationSettings (req: express.Request, res: express.Response) { async function updateNotificationSettings (req: express.Request, res: express.Response) {
const user: UserModel = res.locals.oauth.token.User const user: UserModel = res.locals.oauth.token.User
const body: UserNotificationSetting = req.body const body = req.body
const query = { const query = {
where: { where: {
@ -61,14 +61,19 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
} }
} }
await UserNotificationSettingModel.update({ const values: UserNotificationSetting = {
newVideoFromSubscription: body.newVideoFromSubscription, newVideoFromSubscription: body.newVideoFromSubscription,
newCommentOnMyVideo: body.newCommentOnMyVideo, newCommentOnMyVideo: body.newCommentOnMyVideo,
videoAbuseAsModerator: body.videoAbuseAsModerator, videoAbuseAsModerator: body.videoAbuseAsModerator,
blacklistOnMyVideo: body.blacklistOnMyVideo, blacklistOnMyVideo: body.blacklistOnMyVideo,
myVideoPublished: body.myVideoPublished, myVideoPublished: body.myVideoPublished,
myVideoImportFinished: body.myVideoImportFinished myVideoImportFinished: body.myVideoImportFinished,
}, query) newFollow: body.newFollow,
newUserRegistration: body.newUserRegistration,
commentMention: body.commentMention,
}
await UserNotificationSettingModel.update(values, query)
return res.status(204).end() return res.status(204).end()
} }

View file

@ -27,7 +27,8 @@ function isActorPublicKeyValid (publicKey: string) {
validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY) validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACTORS.PUBLIC_KEY)
} }
const actorNameRegExp = new RegExp('^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_\.]+$') const actorNameAlphabet = '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\\-_.]'
const actorNameRegExp = new RegExp(`^${actorNameAlphabet}+$`)
function isActorPreferredUsernameValid (preferredUsername: string) { function isActorPreferredUsernameValid (preferredUsername: string) {
return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp) return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp)
} }
@ -127,6 +128,7 @@ function areValidActorHandles (handles: string[]) {
export { export {
normalizeActor, normalizeActor,
actorNameAlphabet,
areValidActorHandles, areValidActorHandles,
isActorEndpointsObjectValid, isActorEndpointsObjectValid,
isActorPublicKeyObjectValid, isActorPublicKeyObjectValid,

23
server/helpers/regexp.ts Normal file
View file

@ -0,0 +1,23 @@
// Thanks to https://regex101.com
function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
let m: RegExpExecArray
let i = 0
let result: RegExpExecArray[] = []
// tslint:disable:no-conditional-assignment
while ((m = regex.exec(str)) !== null && i < maxIterations) {
// This is necessary to avoid infinite loops with zero-width matches
if (m.index === regex.lastIndex) {
regex.lastIndex++
}
result.push(m)
i++
}
return result
}
export {
regexpCapture
}

View file

@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL,
"blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL, "blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL,
"myVideoPublished" INTEGER NOT NULL DEFAULT NULL, "myVideoPublished" INTEGER NOT NULL DEFAULT NULL,
"myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL, "myVideoImportFinished" INTEGER NOT NULL DEFAULT NULL,
"newUserRegistration" INTEGER NOT NULL DEFAULT NULL,
"newFollow" INTEGER NOT NULL DEFAULT NULL,
"commentMention" INTEGER NOT NULL DEFAULT NULL,
"userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, "userId" INTEGER REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
@ -26,8 +29,9 @@ PRIMARY KEY ("id"))
{ {
const query = 'INSERT INTO "userNotificationSetting" ' + const query = 'INSERT INTO "userNotificationSetting" ' +
'("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' +
'"myVideoPublished", "myVideoImportFinished", "userId", "createdAt", "updatedAt") ' + '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' +
'(SELECT 2, 2, 4, 4, 2, 2, id, NOW(), NOW() FROM "user")' '"userId", "createdAt", "updatedAt") ' +
'(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")'
await utils.sequelize.query(query) await utils.sequelize.query(query)
} }

View file

@ -2,6 +2,7 @@ import { ActivityAccept } from '../../../../shared/models/activitypub'
import { ActorModel } from '../../../models/activitypub/actor' import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { addFetchOutboxJob } from '../actor' import { addFetchOutboxJob } from '../actor'
import { Notifier } from '../../notifier'
async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) { async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.') if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
@ -24,6 +25,7 @@ async function processAccept (actor: ActorModel, targetActor: ActorModel) {
if (follow.state !== 'accepted') { if (follow.state !== 'accepted') {
follow.set('state', 'accepted') follow.set('state', 'accepted')
await follow.save() await follow.save()
await addFetchOutboxJob(targetActor) await addFetchOutboxJob(targetActor)
} }
} }

View file

@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers'
import { ActorModel } from '../../../models/activitypub/actor' import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { sendAccept } from '../send' import { sendAccept } from '../send'
import { Notifier } from '../../notifier'
async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
const activityObject = activity.object const activityObject = activity.object
@ -21,13 +22,13 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processFollow (actor: ActorModel, targetActorURL: string) { async function processFollow (actor: ActorModel, targetActorURL: string) {
await sequelizeTypescript.transaction(async t => { const { actorFollow, created } = await sequelizeTypescript.transaction(async t => {
const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t) const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
if (!targetActor) throw new Error('Unknown actor') if (!targetActor) throw new Error('Unknown actor')
if (targetActor.isOwned() === false) throw new Error('This is not a local actor.') if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
const [ actorFollow ] = await ActorFollowModel.findOrCreate({ const [ actorFollow, created ] = await ActorFollowModel.findOrCreate({
where: { where: {
actorId: actor.id, actorId: actor.id,
targetActorId: targetActor.id targetActorId: targetActor.id
@ -52,8 +53,12 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
actorFollow.ActorFollowing = targetActor actorFollow.ActorFollowing = targetActor
// Target sends to actor he accepted the follow request // Target sends to actor he accepted the follow request
return sendAccept(actorFollow) await sendAccept(actorFollow)
return { actorFollow, created }
}) })
if (created) Notifier.Instance.notifyOfNewFollow(actorFollow)
logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url) logger.info('Actor %s is followed by actor %s.', targetActorURL, actor.url)
} }

View file

@ -11,6 +11,7 @@ import { VideoCommentModel } from '../models/video/video-comment'
import { VideoAbuseModel } from '../models/video/video-abuse' import { VideoAbuseModel } from '../models/video/video-abuse'
import { VideoBlacklistModel } from '../models/video/video-blacklist' import { VideoBlacklistModel } from '../models/video/video-blacklist'
import { VideoImportModel } from '../models/video/video-import' import { VideoImportModel } from '../models/video/video-import'
import { ActorFollowModel } from '../models/activitypub/actor-follow'
class Emailer { class Emailer {
@ -103,6 +104,25 @@ class Emailer {
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addNewFollowNotification (to: string[], actorFollow: ActorFollowModel, followType: 'account' | 'channel') {
const followerName = actorFollow.ActorFollower.Account.getDisplayName()
const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
const text = `Hi dear user,\n\n` +
`Your ${followType} ${followingName} has a new subscriber: ${followerName}` +
`\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
to,
subject: 'New follower on your channel ' + followingName,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
myVideoPublishedNotification (to: string[], video: VideoModel) { myVideoPublishedNotification (to: string[], video: VideoModel) {
const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
@ -185,7 +205,29 @@ class Emailer {
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) { addNewCommentMentionNotification (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` +
`${accountName} mentioned you on video ${video.name}` +
`\n\n` +
`You can view the comment on ${commentUrl} ` +
`\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
to,
subject: 'Mention on video ' + video.name,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath() const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
const text = `Hi,\n\n` + const text = `Hi,\n\n` +
@ -202,7 +244,22 @@ class Emailer {
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) { addNewUserRegistrationNotification (to: string[], user: UserModel) {
const text = `Hi,\n\n` +
`User ${user.username} just registered on ${CONFIG.WEBSERVER.HOST} PeerTube instance.\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
to,
subject: '[PeerTube] New user registration on ' + CONFIG.WEBSERVER.HOST,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
const videoName = videoBlacklist.Video.name const videoName = videoBlacklist.Video.name
const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
@ -224,7 +281,7 @@ class Emailer {
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
async addVideoUnblacklistNotification (to: string[], video: VideoModel) { addVideoUnblacklistNotification (to: string[], video: VideoModel) {
const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
const text = 'Hi,\n\n' + const text = 'Hi,\n\n' +

View file

@ -8,6 +8,7 @@ import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor'
import { retryTransactionWrapper } from '../../../helpers/database-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { ActorModel } from '../../../models/activitypub/actor' import { ActorModel } from '../../../models/activitypub/actor'
import { Notifier } from '../../notifier'
export type ActivitypubFollowPayload = { export type ActivitypubFollowPayload = {
followerActorId: number followerActorId: number
@ -42,7 +43,7 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function follow (fromActor: ActorModel, targetActor: ActorModel) { async function follow (fromActor: ActorModel, targetActor: ActorModel) {
if (fromActor.id === targetActor.id) { if (fromActor.id === targetActor.id) {
throw new Error('Follower is the same than target actor.') throw new Error('Follower is the same than target actor.')
} }
@ -50,7 +51,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
// Same server, direct accept // Same server, direct accept
const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending' const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
return sequelizeTypescript.transaction(async t => { const actorFollow = await sequelizeTypescript.transaction(async t => {
const [ actorFollow ] = await ActorFollowModel.findOrCreate({ const [ actorFollow ] = await ActorFollowModel.findOrCreate({
where: { where: {
actorId: fromActor.id, actorId: fromActor.id,
@ -68,5 +69,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
// Send a notification to remote server if our follow is not already accepted // Send a notification to remote server if our follow is not already accepted
if (actorFollow.state !== 'accepted') await sendFollow(actorFollow) if (actorFollow.state !== 'accepted') await sendFollow(actorFollow)
return actorFollow
}) })
if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewFollow(actorFollow)
} }

View file

@ -13,6 +13,8 @@ import { VideoBlacklistModel } from '../models/video/video-blacklist'
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import { VideoImportModel } from '../models/video/video-import' import { VideoImportModel } from '../models/video/video-import'
import { AccountBlocklistModel } from '../models/account/account-blocklist' import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { ActorFollowModel } from '../models/activitypub/actor-follow'
import { AccountModel } from '../models/account/account'
class Notifier { class Notifier {
@ -38,7 +40,10 @@ class Notifier {
notifyOnNewComment (comment: VideoCommentModel): void { notifyOnNewComment (comment: VideoCommentModel): void {
this.notifyVideoOwnerOfNewComment(comment) this.notifyVideoOwnerOfNewComment(comment)
.catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err })) .catch(err => logger.error('Cannot notify video owner of new comment %s.', comment.url, { err }))
this.notifyOfCommentMention(comment)
.catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
} }
notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void { notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
@ -61,6 +66,23 @@ class Notifier {
.catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err })) .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
} }
notifyOnNewUserRegistration (user: UserModel): void {
this.notifyModeratorsOfNewUserRegistration(user)
.catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
}
notifyOfNewFollow (actorFollow: ActorFollowModel): void {
this.notifyUserOfNewActorFollow(actorFollow)
.catch(err => {
logger.error(
'Cannot notify owner of channel %s of a new follow by %s.',
actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
actorFollow.ActorFollower.Account.getDisplayName(),
err
)
})
}
private async notifySubscribersOfNewVideo (video: VideoModel) { private async notifySubscribersOfNewVideo (video: VideoModel) {
// List all followers that are users // List all followers that are users
const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId) const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
@ -90,6 +112,8 @@ class Notifier {
} }
private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) { private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
if (comment.Video.isOwned() === false) return
const user = await UserModel.loadByVideoId(comment.videoId) const user = await UserModel.loadByVideoId(comment.videoId)
// Not our user or user comments its own video // Not our user or user comments its own video
@ -122,11 +146,100 @@ class Notifier {
return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
} }
private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) { private async notifyOfCommentMention (comment: VideoCommentModel) {
const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES) const usernames = comment.extractMentions()
let users = await UserModel.listByUsernames(usernames)
if (comment.Video.isOwned()) {
const userException = await UserModel.loadByVideoId(comment.videoId)
users = users.filter(u => u.id !== userException.id)
}
// Don't notify if I mentioned myself
users = users.filter(u => u.Account.id !== comment.accountId)
if (users.length === 0) return if (users.length === 0) return
logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url) const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(users.map(u => u.Account.id), comment.accountId)
logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
function settingGetter (user: UserModel) {
if (accountMutedHash[user.Account.id] === true) return UserNotificationSettingValue.NONE
return user.NotificationSetting.commentMention
}
async function notificationCreator (user: UserModel) {
const notification = await UserNotificationModel.create({
type: UserNotificationType.COMMENT_MENTION,
userId: user.id,
commentId: comment.id
})
notification.Comment = comment
return notification
}
function emailSender (emails: string[]) {
return Emailer.Instance.addNewCommentMentionNotification(emails, comment)
}
return this.notify({ users, settingGetter, notificationCreator, emailSender })
}
private async notifyUserOfNewActorFollow (actorFollow: ActorFollowModel) {
if (actorFollow.ActorFollowing.isOwned() === false) return
// Account follows one of our account?
let followType: 'account' | 'channel' = 'channel'
let user = await UserModel.loadByChannelActorId(actorFollow.ActorFollowing.id)
// Account follows one of our channel?
if (!user) {
user = await UserModel.loadByAccountActorId(actorFollow.ActorFollowing.id)
followType = 'account'
}
if (!user) return
if (!actorFollow.ActorFollower.Account || !actorFollow.ActorFollower.Account.name) {
actorFollow.ActorFollower.Account = await actorFollow.ActorFollower.$get('Account') as AccountModel
}
const followerAccount = actorFollow.ActorFollower.Account
const accountMuted = await AccountBlocklistModel.isAccountMutedBy(user.Account.id, followerAccount.id)
if (accountMuted) return
logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
function settingGetter (user: UserModel) {
return user.NotificationSetting.newFollow
}
async function notificationCreator (user: UserModel) {
const notification = await UserNotificationModel.create({
type: UserNotificationType.NEW_FOLLOW,
userId: user.id,
actorFollowId: actorFollow.id
})
notification.ActorFollow = actorFollow
return notification
}
function emailSender (emails: string[]) {
return Emailer.Instance.addNewFollowNotification(emails, actorFollow, followType)
}
return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
}
private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
if (moderators.length === 0) return
logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, videoAbuse.Video.url)
function settingGetter (user: UserModel) { function settingGetter (user: UserModel) {
return user.NotificationSetting.videoAbuseAsModerator return user.NotificationSetting.videoAbuseAsModerator
@ -147,7 +260,7 @@ class Notifier {
return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse) return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
} }
return this.notify({ users, settingGetter, notificationCreator, emailSender }) return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
} }
private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) { private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
@ -264,6 +377,37 @@ class Notifier {
return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
} }
private async notifyModeratorsOfNewUserRegistration (registeredUser: UserModel) {
const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
if (moderators.length === 0) return
logger.info(
'Notifying %s moderators of new user registration of %s.',
moderators.length, registeredUser.Account.Actor.preferredUsername
)
function settingGetter (user: UserModel) {
return user.NotificationSetting.newUserRegistration
}
async function notificationCreator (user: UserModel) {
const notification = await UserNotificationModel.create({
type: UserNotificationType.NEW_USER_REGISTRATION,
userId: user.id,
accountId: registeredUser.Account.id
})
notification.Account = registeredUser.Account
return notification
}
function emailSender (emails: string[]) {
return Emailer.Instance.addNewUserRegistrationNotification(emails, registeredUser)
}
return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
}
private async notify (options: { private async notify (options: {
users: UserModel[], users: UserModel[],
notificationCreator: (user: UserModel) => Promise<UserNotificationModel>, notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,

View file

@ -10,7 +10,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
import { ActorModel } from '../models/activitypub/actor' import { ActorModel } from '../models/activitypub/actor'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting' import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
import { UserNotificationSettingValue } from '../../shared/models/users' import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
@ -96,13 +96,18 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
return UserNotificationSettingModel.create({ const values: UserNotificationSetting & { userId: number } = {
userId: user.id, userId: user.id,
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
}, { transaction: t }) newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION
}
return UserNotificationSettingModel.create(values, { transaction: t })
} }

View file

@ -2,6 +2,7 @@ import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, Updated
import { AccountModel } from './account' import { AccountModel } from './account'
import { getSort } from '../utils' import { getSort } from '../utils'
import { AccountBlock } from '../../../shared/models/blocklist' import { AccountBlock } from '../../../shared/models/blocklist'
import { Op } from 'sequelize'
enum ScopeNames { enum ScopeNames {
WITH_ACCOUNTS = 'WITH_ACCOUNTS' WITH_ACCOUNTS = 'WITH_ACCOUNTS'
@ -73,18 +74,33 @@ export class AccountBlocklistModel extends Model<AccountBlocklistModel> {
BlockedAccount: AccountModel BlockedAccount: AccountModel
static isAccountMutedBy (accountId: number, targetAccountId: number) { static isAccountMutedBy (accountId: number, targetAccountId: number) {
return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId)
.then(result => result[accountId])
}
static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) {
const query = { const query = {
attributes: [ 'id' ], attributes: [ 'accountId', 'id' ],
where: { where: {
accountId, accountId: {
[Op.any]: accountIds
},
targetAccountId targetAccountId
}, },
raw: true raw: true
} }
return AccountBlocklistModel.unscoped() return AccountBlocklistModel.unscoped()
.findOne(query) .findAll(query)
.then(a => !!a) .then(rows => {
const result: { [accountId: number]: boolean } = {}
for (const accountId of accountIds) {
result[accountId] = !!rows.find(r => r.accountId === accountId)
}
return result
})
} }
static loadByAccountAndTarget (accountId: number, targetAccountId: number) { static loadByAccountAndTarget (accountId: number, targetAccountId: number) {

View file

@ -83,6 +83,33 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
@Column @Column
myVideoImportFinished: UserNotificationSettingValue myVideoImportFinished: UserNotificationSettingValue
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewUserRegistration',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
)
@Column
newUserRegistration: UserNotificationSettingValue
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewFollow',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
)
@Column
newFollow: UserNotificationSettingValue
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingCommentMention',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
)
@Column
commentMention: UserNotificationSettingValue
@ForeignKey(() => UserModel) @ForeignKey(() => UserModel)
@Column @Column
userId: number userId: number
@ -114,7 +141,10 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
videoAbuseAsModerator: this.videoAbuseAsModerator, videoAbuseAsModerator: this.videoAbuseAsModerator,
blacklistOnMyVideo: this.blacklistOnMyVideo, blacklistOnMyVideo: this.blacklistOnMyVideo,
myVideoPublished: this.myVideoPublished, myVideoPublished: this.myVideoPublished,
myVideoImportFinished: this.myVideoImportFinished myVideoImportFinished: this.myVideoImportFinished,
newUserRegistration: this.newUserRegistration,
commentMention: this.commentMention,
newFollow: this.newFollow
} }
} }
} }

View file

@ -25,6 +25,8 @@ import { AccountModel } from './account'
import { VideoAbuseModel } from '../video/video-abuse' import { VideoAbuseModel } from '../video/video-abuse'
import { VideoBlacklistModel } from '../video/video-blacklist' import { VideoBlacklistModel } from '../video/video-blacklist'
import { VideoImportModel } from '../video/video-import' import { VideoImportModel } from '../video/video-import'
import { ActorModel } from '../activitypub/actor'
import { ActorFollowModel } from '../activitypub/actor-follow'
enum ScopeNames { enum ScopeNames {
WITH_ALL = 'WITH_ALL' WITH_ALL = 'WITH_ALL'
@ -38,17 +40,17 @@ function buildVideoInclude (required: boolean) {
} }
} }
function buildChannelInclude () { function buildChannelInclude (required: boolean) {
return { return {
required: true, required,
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
model: () => VideoChannelModel.unscoped() model: () => VideoChannelModel.unscoped()
} }
} }
function buildAccountInclude () { function buildAccountInclude (required: boolean) {
return { return {
required: true, required,
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
model: () => AccountModel.unscoped() model: () => AccountModel.unscoped()
} }
@ -58,14 +60,14 @@ function buildAccountInclude () {
[ScopeNames.WITH_ALL]: { [ScopeNames.WITH_ALL]: {
include: [ include: [
Object.assign(buildVideoInclude(false), { Object.assign(buildVideoInclude(false), {
include: [ buildChannelInclude() ] include: [ buildChannelInclude(true) ]
}), }),
{ {
attributes: [ 'id', 'originCommentId' ], attributes: [ 'id', 'originCommentId' ],
model: () => VideoCommentModel.unscoped(), model: () => VideoCommentModel.unscoped(),
required: false, required: false,
include: [ include: [
buildAccountInclude(), buildAccountInclude(true),
buildVideoInclude(true) buildVideoInclude(true)
] ]
}, },
@ -86,6 +88,42 @@ function buildAccountInclude () {
model: () => VideoImportModel.unscoped(), model: () => VideoImportModel.unscoped(),
required: false, required: false,
include: [ buildVideoInclude(false) ] include: [ buildVideoInclude(false) ]
},
{
attributes: [ 'id', 'name' ],
model: () => AccountModel.unscoped(),
required: false,
include: [
{
attributes: [ 'id', 'preferredUsername' ],
model: () => ActorModel.unscoped(),
required: true
}
]
},
{
attributes: [ 'id' ],
model: () => ActorFollowModel.unscoped(),
required: false,
include: [
{
attributes: [ 'preferredUsername' ],
model: () => ActorModel.unscoped(),
required: true,
as: 'ActorFollower',
include: [ buildAccountInclude(true) ]
},
{
attributes: [ 'preferredUsername' ],
model: () => ActorModel.unscoped(),
required: true,
as: 'ActorFollowing',
include: [
buildChannelInclude(false),
buildAccountInclude(false)
]
}
]
} }
] ]
} }
@ -193,6 +231,30 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
}) })
VideoImport: VideoImportModel VideoImport: VideoImportModel
@ForeignKey(() => AccountModel)
@Column
accountId: number
@BelongsTo(() => AccountModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
Account: AccountModel
@ForeignKey(() => ActorFollowModel)
@Column
actorFollowId: number
@BelongsTo(() => ActorFollowModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
ActorFollow: ActorFollowModel
static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) { static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const query: IFindOptions<UserNotificationModel> = { const query: IFindOptions<UserNotificationModel> = {
offset: start, offset: start,
@ -264,6 +326,25 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
video: this.formatVideo(this.VideoBlacklist.Video) video: this.formatVideo(this.VideoBlacklist.Video)
} : undefined } : undefined
const account = this.Account ? {
id: this.Account.id,
displayName: this.Account.getDisplayName(),
name: this.Account.Actor.preferredUsername
} : undefined
const actorFollow = this.ActorFollow ? {
id: this.ActorFollow.id,
follower: {
displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
name: this.ActorFollow.ActorFollower.preferredUsername
},
following: {
type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
name: this.ActorFollow.ActorFollowing.preferredUsername
}
} : undefined
return { return {
id: this.id, id: this.id,
type: this.type, type: this.type,
@ -273,6 +354,8 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
comment, comment,
videoAbuse, videoAbuse,
videoBlacklist, videoBlacklist,
account,
actorFollow,
createdAt: this.createdAt.toISOString(), createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString() updatedAt: this.updatedAt.toISOString()
} }

View file

@ -330,6 +330,16 @@ export class UserModel extends Model<UserModel> {
return UserModel.unscoped().findAll(query) return UserModel.unscoped().findAll(query)
} }
static listByUsernames (usernames: string[]) {
const query = {
where: {
username: usernames
}
}
return UserModel.findAll(query)
}
static loadById (id: number) { static loadById (id: number) {
return UserModel.findById(id) return UserModel.findById(id)
} }
@ -424,6 +434,47 @@ export class UserModel extends Model<UserModel> {
return UserModel.findOne(query) return UserModel.findOne(query)
} }
static loadByChannelActorId (videoChannelActorId: number) {
const query = {
include: [
{
required: true,
attributes: [ 'id' ],
model: AccountModel.unscoped(),
include: [
{
required: true,
attributes: [ 'id' ],
model: VideoChannelModel.unscoped(),
where: {
actorId: videoChannelActorId
}
}
]
}
]
}
return UserModel.findOne(query)
}
static loadByAccountActorId (accountActorId: number) {
const query = {
include: [
{
required: true,
attributes: [ 'id' ],
model: AccountModel.unscoped(),
where: {
actorId: accountActorId
}
}
]
}
return UserModel.findOne(query)
}
static getOriginalVideoFileTotalFromUser (user: UserModel) { static getOriginalVideoFileTotalFromUser (user: UserModel) {
// Don't use sequelize because we need to use a sub query // Don't use sequelize because we need to use a sub query
const query = UserModel.generateUserQuotaBaseSQL() const query = UserModel.generateUserQuotaBaseSQL()

View file

@ -18,7 +18,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
import { VideoComment } from '../../../shared/models/videos/video-comment.model' import { VideoComment } from '../../../shared/models/videos/video-comment.model'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { CONSTRAINTS_FIELDS } from '../../initializers' import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers'
import { sendDeleteVideoComment } from '../../lib/activitypub/send' import { sendDeleteVideoComment } from '../../lib/activitypub/send'
import { AccountModel } from '../account/account' import { AccountModel } from '../account/account'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
@ -29,6 +29,9 @@ import { VideoModel } from './video'
import { VideoChannelModel } from './video-channel' import { VideoChannelModel } from './video-channel'
import { getServerActor } from '../../helpers/utils' import { getServerActor } from '../../helpers/utils'
import { UserModel } from '../account/user' import { UserModel } from '../account/user'
import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
import { regexpCapture } from '../../helpers/regexp'
import { uniq } from 'lodash'
enum ScopeNames { enum ScopeNames {
WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACCOUNT = 'WITH_ACCOUNT',
@ -370,9 +373,11 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
id: { id: {
[ Sequelize.Op.in ]: Sequelize.literal('(' + [ Sequelize.Op.in ]: Sequelize.literal('(' +
'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + 'UNION ' +
'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
') ' +
'SELECT id FROM children' + 'SELECT id FROM children' +
')'), ')'),
[ Sequelize.Op.ne ]: comment.id [ Sequelize.Op.ne ]: comment.id
@ -460,6 +465,34 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
return this.Account.isOwned() return this.Account.isOwned()
} }
extractMentions () {
if (!this.text) return []
const localMention = `@(${actorNameAlphabet}+)`
const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}`
const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
const firstMentionRegex = new RegExp('^(?:(?:' + remoteMention + ')|(?:' + localMention + ')) ', 'g')
const endMentionRegex = new RegExp(' (?:(?:' + remoteMention + ')|(?:' + localMention + '))$', 'g')
return uniq(
[].concat(
regexpCapture(this.text, remoteMentionsRegex)
.map(([ , username ]) => username),
regexpCapture(this.text, localMentionsRegex)
.map(([ , username ]) => username),
regexpCapture(this.text, firstMentionRegex)
.map(([ , username1, username2 ]) => username1 || username2),
regexpCapture(this.text, endMentionRegex)
.map(([ , username1, username2 ]) => username1 || username2)
)
)
}
toFormattedJSON () { toFormattedJSON () {
return { return {
id: this.id, id: this.id,

View file

@ -139,7 +139,10 @@ describe('Test user notifications API validators', function () {
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION,
newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION
} }
it('Should fail with missing fields', async function () { it('Should fail with missing fields', async function () {

View file

@ -10,9 +10,12 @@ import {
flushTests, flushTests,
getMyUserInformation, getMyUserInformation,
immutableAssign, immutableAssign,
registerUser,
removeVideoFromBlacklist, removeVideoFromBlacklist,
reportVideoAbuse, reportVideoAbuse,
updateMyUser,
updateVideo, updateVideo,
updateVideoChannel,
userLogin, userLogin,
wait wait
} from '../../../../shared/utils' } from '../../../../shared/utils'
@ -21,16 +24,20 @@ import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
import { waitJobs } from '../../../../shared/utils/server/jobs' import { waitJobs } from '../../../../shared/utils/server/jobs'
import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io' import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
import { import {
checkCommentMention,
CheckerBaseParams, CheckerBaseParams,
checkMyVideoImportIsFinished,
checkNewActorFollow,
checkNewBlacklistOnMyVideo, checkNewBlacklistOnMyVideo,
checkNewCommentOnMyVideo, checkNewCommentOnMyVideo,
checkNewVideoAbuseForModerators, checkNewVideoAbuseForModerators,
checkNewVideoFromSubscription, checkNewVideoFromSubscription,
checkUserRegistered,
checkVideoIsPublished,
getLastNotification, getLastNotification,
getUserNotifications, getUserNotifications,
markAsReadNotifications, markAsReadNotifications,
updateMyNotificationSettings, updateMyNotificationSettings
checkVideoIsPublished, checkMyVideoImportIsFinished
} from '../../../../shared/utils/users/user-notifications' } from '../../../../shared/utils/users/user-notifications'
import { import {
User, User,
@ -40,9 +47,9 @@ import {
UserNotificationType UserNotificationType
} from '../../../../shared/models/users' } from '../../../../shared/models/users'
import { MockSmtpServer } from '../../../../shared/utils/miscs/email' import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions' import { addUserSubscription, removeUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
import { VideoPrivacy } from '../../../../shared/models/videos' import { VideoPrivacy } from '../../../../shared/models/videos'
import { getYoutubeVideoUrl, importVideo, getBadVideoUrl } from '../../../../shared/utils/videos/video-imports' import { getBadVideoUrl, getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments' import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
import * as uuidv4 from 'uuid/v4' import * as uuidv4 from 'uuid/v4'
import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist' import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist'
@ -81,12 +88,15 @@ describe('Test users notifications', function () {
let channelId: number let channelId: number
const allNotificationSettings: UserNotificationSetting = { const allNotificationSettings: UserNotificationSetting = {
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
commentMention: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
newFollow: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
} }
before(async function () { before(async function () {
@ -424,6 +434,114 @@ describe('Test users notifications', function () {
}) })
}) })
describe('Mention notifications', function () {
let baseParams: CheckerBaseParams
before(async () => {
baseParams = {
server: servers[0],
emails,
socketNotifications: userNotifications,
token: userAccessToken
}
await updateMyUser({
url: servers[0].url,
accessToken: servers[0].accessToken,
displayName: 'super root name'
})
await updateMyUser({
url: servers[1].url,
accessToken: servers[1].accessToken,
displayName: 'super root 2 name'
})
})
it('Should not send a new mention comment notification if I mention the video owner', 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, '@user_1 hello')
const commentId = resComment.body.comment.id
await wait(500)
await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
})
it('Should not send a new mention comment notification if I mention myself', 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, userAccessToken, uuid, '@user_1 hello')
const commentId = resComment.body.comment.id
await wait(500)
await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
})
it('Should not send a new mention notification if the account is muted', async function () {
this.timeout(10000)
await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
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, '@user_1 hello')
const commentId = resComment.body.comment.id
await wait(500)
await checkCommentMention(baseParams, uuid, commentId, commentId, 'super root name', 'absence')
await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessToken, 'root')
})
it('Should send a new mention notification after local comments', 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 resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, '@user_1 hello 1')
const threadId = resThread.body.comment.id
await wait(500)
await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root name', 'presence')
const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'hello 2 @user_1')
const commentId = resComment.body.comment.id
await wait(500)
await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root name', 'presence')
})
it('Should send a new mention notification after remote comments', async function () {
this.timeout(20000)
const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
const uuid = resVideo.body.video.uuid
await waitJobs(servers)
const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'hello @user_1@localhost:9001 1')
const threadId = resThread.body.comment.id
await waitJobs(servers)
await checkCommentMention(baseParams, uuid, threadId, threadId, 'super root 2 name', 'presence')
const text = '@user_1@localhost:9001 hello 2 @root@localhost:9001'
const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, text)
const commentId = resComment.body.comment.id
await waitJobs(servers)
await checkCommentMention(baseParams, uuid, commentId, threadId, 'super root 2 name', 'presence')
})
})
describe('Video abuse for moderators notification' , function () { describe('Video abuse for moderators notification' , function () {
let baseParams: CheckerBaseParams let baseParams: CheckerBaseParams
@ -645,6 +763,101 @@ describe('Test users notifications', function () {
}) })
}) })
describe('New registration', function () {
let baseParams: CheckerBaseParams
before(() => {
baseParams = {
server: servers[0],
emails,
socketNotifications: adminNotifications,
token: servers[0].accessToken
}
})
it('Should send a notification only to moderators when a user registers on the instance', async function () {
await registerUser(servers[0].url, 'user_45', 'password')
await waitJobs(servers)
await checkUserRegistered(baseParams, 'user_45', 'presence')
const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
await checkUserRegistered(immutableAssign(baseParams, userOverride), 'user_45', 'absence')
})
})
describe('New actor follow', function () {
let baseParams: CheckerBaseParams
let myChannelName = 'super channel name'
let myUserName = 'super user name'
before(async () => {
baseParams = {
server: servers[0],
emails,
socketNotifications: userNotifications,
token: userAccessToken
}
await updateMyUser({
url: servers[0].url,
accessToken: servers[0].accessToken,
displayName: 'super root name'
})
await updateMyUser({
url: servers[0].url,
accessToken: userAccessToken,
displayName: myUserName
})
await updateMyUser({
url: servers[1].url,
accessToken: servers[1].accessToken,
displayName: 'super root 2 name'
})
await updateVideoChannel(servers[0].url, userAccessToken, 'user_1_channel', { displayName: myChannelName })
})
it('Should notify when a local channel is following one of our channel', async function () {
await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root name', myChannelName, 'presence')
await removeUserSubscription(servers[0].url, servers[0].accessToken, 'user_1_channel@localhost:9001')
})
it('Should notify when a remote channel is following one of our channel', async function () {
await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'channel', 'root', 'super root 2 name', myChannelName, 'presence')
await removeUserSubscription(servers[1].url, servers[1].accessToken, 'user_1_channel@localhost:9001')
})
it('Should notify when a local account is following one of our channel', async function () {
await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'account', 'root', 'super root name', myUserName, 'presence')
})
it('Should notify when a remote account is following one of our channel', async function () {
await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001')
await waitJobs(servers)
await checkNewActorFollow(baseParams, 'account', 'root', 'super root 2 name', myUserName, 'presence')
})
})
describe('Mark as read', function () { describe('Mark as read', function () {
it('Should mark as read some notifications', async function () { it('Should mark as read some notifications', async function () {
const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3) const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3)

View file

@ -0,0 +1,25 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import { VideoCommentModel } from '../../models/video/video-comment'
const expect = chai.expect
class CommentMock {
text: string
extractMentions = VideoCommentModel.prototype.extractMentions
}
describe('Comment model', function () {
it('Should correctly extract mentions', async function () {
const comment = new CommentMock()
comment.text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' +
'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end'
const result = comment.extractMentions().sort()
expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ])
})
})

View file

@ -1 +1,2 @@
import './core-utils' import './core-utils'
import './comment-model'

View file

@ -12,4 +12,7 @@ export interface UserNotificationSetting {
blacklistOnMyVideo: UserNotificationSettingValue blacklistOnMyVideo: UserNotificationSettingValue
myVideoPublished: UserNotificationSettingValue myVideoPublished: UserNotificationSettingValue
myVideoImportFinished: UserNotificationSettingValue myVideoImportFinished: UserNotificationSettingValue
newUserRegistration: UserNotificationSettingValue
newFollow: UserNotificationSettingValue
commentMention: UserNotificationSettingValue
} }

View file

@ -6,7 +6,10 @@ export enum UserNotificationType {
UNBLACKLIST_ON_MY_VIDEO = 5, UNBLACKLIST_ON_MY_VIDEO = 5,
MY_VIDEO_PUBLISHED = 6, MY_VIDEO_PUBLISHED = 6,
MY_VIDEO_IMPORT_SUCCESS = 7, MY_VIDEO_IMPORT_SUCCESS = 7,
MY_VIDEO_IMPORT_ERROR = 8 MY_VIDEO_IMPORT_ERROR = 8,
NEW_USER_REGISTRATION = 9,
NEW_FOLLOW = 10,
COMMENT_MENTION = 11
} }
export interface VideoInfo { export interface VideoInfo {
@ -55,6 +58,25 @@ export interface UserNotification {
video: VideoInfo video: VideoInfo
} }
account?: {
id: number
displayName: string
name: string
}
actorFollow?: {
id: number
follower: {
name: string
displayName: string
}
following: {
type: 'account' | 'channel'
name: string
displayName: string
}
}
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }

View file

@ -2,10 +2,15 @@ export enum UserRight {
ALL, ALL,
MANAGE_USERS, MANAGE_USERS,
MANAGE_SERVER_FOLLOW, MANAGE_SERVER_FOLLOW,
MANAGE_SERVER_REDUNDANCY, MANAGE_SERVER_REDUNDANCY,
MANAGE_VIDEO_ABUSES, MANAGE_VIDEO_ABUSES,
MANAGE_JOBS, MANAGE_JOBS,
MANAGE_CONFIGURATION, MANAGE_CONFIGURATION,
MANAGE_ACCOUNTS_BLOCKLIST, MANAGE_ACCOUNTS_BLOCKLIST,

View file

@ -29,7 +29,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = {
UserRight.UPDATE_ANY_VIDEO, UserRight.UPDATE_ANY_VIDEO,
UserRight.SEE_ALL_VIDEOS, UserRight.SEE_ALL_VIDEOS,
UserRight.MANAGE_ACCOUNTS_BLOCKLIST, UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
UserRight.MANAGE_SERVERS_BLOCKLIST UserRight.MANAGE_SERVERS_BLOCKLIST,
UserRight.MANAGE_USERS
], ],
[UserRole.USER]: [] [UserRole.USER]: []

View file

@ -98,9 +98,11 @@ async function checkNotification (
}) })
if (checkType === 'presence') { if (checkType === 'presence') {
expect(socketNotification, 'The socket notification is absent. ' + inspect(base.socketNotifications)).to.not.be.undefined const obj = inspect(base.socketNotifications, { depth: 5 })
expect(socketNotification, 'The socket notification is absent. ' + obj).to.not.be.undefined
} else { } else {
expect(socketNotification, 'The socket notification is present. ' + inspect(socketNotification)).to.be.undefined const obj = inspect(socketNotification, { depth: 5 })
expect(socketNotification, 'The socket notification is present. ' + obj).to.be.undefined
} }
} }
@ -131,10 +133,9 @@ function checkVideo (video: any, videoName?: string, videoUUID?: string) {
expect(video.id).to.be.a('number') expect(video.id).to.be.a('number')
} }
function checkActor (channel: any) { function checkActor (actor: any) {
expect(channel.id).to.be.a('number') expect(actor.displayName).to.be.a('string')
expect(channel.displayName).to.be.a('string') expect(actor.displayName).to.not.be.empty
expect(channel.displayName).to.not.be.empty
} }
function checkComment (comment: any, commentId: number, threadId: number) { function checkComment (comment: any, commentId: number, threadId: number) {
@ -220,6 +221,103 @@ async function checkMyVideoImportIsFinished (
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailFinder, type)
} }
async function checkUserRegistered (base: CheckerBaseParams, username: string, type: CheckerType) {
const notificationType = UserNotificationType.NEW_USER_REGISTRATION
function notificationChecker (notification: UserNotification, type: CheckerType) {
if (type === 'presence') {
expect(notification).to.not.be.undefined
expect(notification.type).to.equal(notificationType)
checkActor(notification.account)
expect(notification.account.name).to.equal(username)
} else {
expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username)
}
}
function emailFinder (email: object) {
const text: string = email[ 'text' ]
return text.includes(' registered ') && text.includes(username)
}
await checkNotification(base, notificationChecker, emailFinder, type)
}
async function checkNewActorFollow (
base: CheckerBaseParams,
followType: 'channel' | 'account',
followerName: string,
followerDisplayName: string,
followingDisplayName: string,
type: CheckerType
) {
const notificationType = UserNotificationType.NEW_FOLLOW
function notificationChecker (notification: UserNotification, type: CheckerType) {
if (type === 'presence') {
expect(notification).to.not.be.undefined
expect(notification.type).to.equal(notificationType)
checkActor(notification.actorFollow.follower)
expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName)
expect(notification.actorFollow.follower.name).to.equal(followerName)
checkActor(notification.actorFollow.following)
expect(notification.actorFollow.following.displayName).to.equal(followingDisplayName)
expect(notification.actorFollow.following.type).to.equal(followType)
} else {
expect(notification).to.satisfy(n => {
return n.type !== notificationType ||
(n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName)
})
}
}
function emailFinder (email: object) {
const text: string = email[ 'text' ]
return text.includes('Your ' + followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName)
}
await checkNotification(base, notificationChecker, emailFinder, type)
}
async function checkCommentMention (
base: CheckerBaseParams,
uuid: string,
commentId: number,
threadId: number,
byAccountDisplayName: string,
type: CheckerType
) {
const notificationType = UserNotificationType.COMMENT_MENTION
function notificationChecker (notification: UserNotification, type: CheckerType) {
if (type === 'presence') {
expect(notification).to.not.be.undefined
expect(notification.type).to.equal(notificationType)
checkComment(notification.comment, commentId, threadId)
checkActor(notification.comment.account)
expect(notification.comment.account.displayName).to.equal(byAccountDisplayName)
checkVideo(notification.comment.video, undefined, uuid)
} else {
expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId)
}
}
function emailFinder (email: object) {
const text: string = email[ 'text' ]
return text.includes(' mentioned ') && text.includes(uuid) && text.includes(byAccountDisplayName)
}
await checkNotification(base, notificationChecker, emailFinder, type)
}
let lastEmailCount = 0 let lastEmailCount = 0
async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) { async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) {
const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
@ -312,10 +410,13 @@ export {
CheckerType, CheckerType,
checkNotification, checkNotification,
checkMyVideoImportIsFinished, checkMyVideoImportIsFinished,
checkUserRegistered,
checkVideoIsPublished, checkVideoIsPublished,
checkNewVideoFromSubscription, checkNewVideoFromSubscription,
checkNewActorFollow,
checkNewCommentOnMyVideo, checkNewCommentOnMyVideo,
checkNewBlacklistOnMyVideo, checkNewBlacklistOnMyVideo,
checkCommentMention,
updateMyNotificationSettings, updateMyNotificationSettings,
checkNewVideoAbuseForModerators, checkNewVideoAbuseForModerators,
getUserNotifications, getUserNotifications,