From f7cc67b455a12ccae9b0ea16876d166720364357 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 4 Jan 2019 08:56:20 +0100 Subject: Add new follow, mention and user registered notifs --- server/controllers/api/users/index.ts | 3 + server/controllers/api/users/my-notifications.ts | 15 +- .../helpers/custom-validators/activitypub/actor.ts | 4 +- server/helpers/regexp.ts | 23 +++ .../migrations/0315-user-notifications.ts | 8 +- server/lib/activitypub/process/process-accept.ts | 2 + server/lib/activitypub/process/process-follow.ts | 11 +- server/lib/emailer.ts | 63 +++++- .../lib/job-queue/handlers/activitypub-follow.ts | 9 +- server/lib/notifier.ts | 154 +++++++++++++- server/lib/user.ts | 13 +- server/models/account/account-blocklist.ts | 24 ++- server/models/account/user-notification-setting.ts | 32 ++- server/models/account/user-notification.ts | 95 ++++++++- server/models/account/user.ts | 51 +++++ server/models/video/video-comment.ts | 41 +++- .../tests/api/check-params/user-notifications.ts | 5 +- server/tests/api/users/user-notifications.ts | 229 ++++++++++++++++++++- server/tests/helpers/comment-model.ts | 25 +++ server/tests/helpers/index.ts | 1 + .../users/user-notification-setting.model.ts | 3 + shared/models/users/user-notification.model.ts | 24 ++- shared/models/users/user-right.enum.ts | 5 + shared/models/users/user-role.ts | 3 +- shared/utils/users/user-notifications.ts | 113 +++++++++- 25 files changed, 899 insertions(+), 57 deletions(-) create mode 100644 server/helpers/regexp.ts create mode 100644 server/tests/helpers/comment-model.ts diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 98be46ea2..9e6a019f6 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -40,6 +40,7 @@ import { deleteUserToken } from '../../../lib/oauth-model' import { myBlocklistRouter } from './my-blocklist' import { myVideosHistoryRouter } from './my-history' import { myNotificationsRouter } from './my-notifications' +import { Notifier } from '../../../lib/notifier' const auditLogger = auditLoggerFactory('users') @@ -213,6 +214,8 @@ async function registerUser (req: express.Request, res: express.Response) { await sendVerifyUserEmail(user) } + Notifier.Instance.notifyOnNewUserRegistration(user) + return res.type('json').status(204).end() } diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts index 4b81777a4..d74d26add 100644 --- a/server/controllers/api/users/my-notifications.ts +++ b/server/controllers/api/users/my-notifications.ts @@ -18,7 +18,7 @@ import { markAsReadUserNotificationsValidator, updateNotificationSettingsValidator } 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' const myNotificationsRouter = express.Router() @@ -53,7 +53,7 @@ export { async function updateNotificationSettings (req: express.Request, res: express.Response) { const user: UserModel = res.locals.oauth.token.User - const body: UserNotificationSetting = req.body + const body = req.body const query = { where: { @@ -61,14 +61,19 @@ async function updateNotificationSettings (req: express.Request, res: express.Re } } - await UserNotificationSettingModel.update({ + const values: UserNotificationSetting = { newVideoFromSubscription: body.newVideoFromSubscription, newCommentOnMyVideo: body.newCommentOnMyVideo, videoAbuseAsModerator: body.videoAbuseAsModerator, blacklistOnMyVideo: body.blacklistOnMyVideo, myVideoPublished: body.myVideoPublished, - myVideoImportFinished: body.myVideoImportFinished - }, query) + myVideoImportFinished: body.myVideoImportFinished, + newFollow: body.newFollow, + newUserRegistration: body.newUserRegistration, + commentMention: body.commentMention, + } + + await UserNotificationSettingModel.update(values, query) return res.status(204).end() } diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts index 77c003cdf..070632a20 100644 --- a/server/helpers/custom-validators/activitypub/actor.ts +++ b/server/helpers/custom-validators/activitypub/actor.ts @@ -27,7 +27,8 @@ function isActorPublicKeyValid (publicKey: string) { 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) { return exists(preferredUsername) && validator.matches(preferredUsername, actorNameRegExp) } @@ -127,6 +128,7 @@ function areValidActorHandles (handles: string[]) { export { normalizeActor, + actorNameAlphabet, areValidActorHandles, isActorEndpointsObjectValid, isActorPublicKeyObjectValid, diff --git a/server/helpers/regexp.ts b/server/helpers/regexp.ts new file mode 100644 index 000000000..2336654b0 --- /dev/null +++ b/server/helpers/regexp.ts @@ -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 +} diff --git a/server/initializers/migrations/0315-user-notifications.ts b/server/initializers/migrations/0315-user-notifications.ts index 8c54c5d6c..34f9fd193 100644 --- a/server/initializers/migrations/0315-user-notifications.ts +++ b/server/initializers/migrations/0315-user-notifications.ts @@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS "userNotificationSetting" ("id" SERIAL, "blacklistOnMyVideo" INTEGER NOT NULL DEFAULT NULL, "myVideoPublished" 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, "createdAt" 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" ' + '("newVideoFromSubscription", "newCommentOnMyVideo", "videoAbuseAsModerator", "blacklistOnMyVideo", ' + - '"myVideoPublished", "myVideoImportFinished", "userId", "createdAt", "updatedAt") ' + - '(SELECT 2, 2, 4, 4, 2, 2, id, NOW(), NOW() FROM "user")' + '"myVideoPublished", "myVideoImportFinished", "newUserRegistration", "newFollow", "commentMention", ' + + '"userId", "createdAt", "updatedAt") ' + + '(SELECT 2, 2, 4, 4, 2, 2, 2, 2, 2, id, NOW(), NOW() FROM "user")' await utils.sequelize.query(query) } diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts index 89bda9c32..605705ad3 100644 --- a/server/lib/activitypub/process/process-accept.ts +++ b/server/lib/activitypub/process/process-accept.ts @@ -2,6 +2,7 @@ import { ActivityAccept } from '../../../../shared/models/activitypub' import { ActorModel } from '../../../models/activitypub/actor' import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { addFetchOutboxJob } from '../actor' +import { Notifier } from '../../notifier' async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) { 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') { follow.set('state', 'accepted') await follow.save() + await addFetchOutboxJob(targetActor) } } diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts index 24c9085f7..a67892440 100644 --- a/server/lib/activitypub/process/process-follow.ts +++ b/server/lib/activitypub/process/process-follow.ts @@ -5,6 +5,7 @@ import { sequelizeTypescript } from '../../../initializers' import { ActorModel } from '../../../models/activitypub/actor' import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { sendAccept } from '../send' +import { Notifier } from '../../notifier' async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { const activityObject = activity.object @@ -21,13 +22,13 @@ export { // --------------------------------------------------------------------------- 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) if (!targetActor) throw new Error('Unknown 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: { actorId: actor.id, targetActorId: targetActor.id @@ -52,8 +53,12 @@ async function processFollow (actor: ActorModel, targetActorURL: string) { actorFollow.ActorFollowing = targetActor // 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) } diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index 6dc8f2adf..3429498e7 100644 --- a/server/lib/emailer.ts +++ b/server/lib/emailer.ts @@ -11,6 +11,7 @@ import { VideoCommentModel } from '../models/video/video-comment' import { VideoAbuseModel } from '../models/video/video-abuse' import { VideoBlacklistModel } from '../models/video/video-blacklist' import { VideoImportModel } from '../models/video/video-import' +import { ActorFollowModel } from '../models/activitypub/actor-follow' class Emailer { @@ -103,6 +104,25 @@ class Emailer { 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) { const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath() @@ -185,7 +205,29 @@ class Emailer { 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 text = `Hi,\n\n` + @@ -202,7 +244,22 @@ class Emailer { 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 videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() @@ -224,7 +281,7 @@ class Emailer { 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 text = 'Hi,\n\n' + diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts index 36d0f237b..b4d381062 100644 --- a/server/lib/job-queue/handlers/activitypub-follow.ts +++ b/server/lib/job-queue/handlers/activitypub-follow.ts @@ -8,6 +8,7 @@ import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor' import { retryTransactionWrapper } from '../../../helpers/database-utils' import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { ActorModel } from '../../../models/activitypub/actor' +import { Notifier } from '../../notifier' export type ActivitypubFollowPayload = { 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) { throw new Error('Follower is the same than target actor.') } @@ -50,7 +51,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) { // Same server, direct accept 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({ where: { 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 if (actorFollow.state !== 'accepted') await sendFollow(actorFollow) + + return actorFollow }) + + if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewFollow(actorFollow) } diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts index 11b0937e9..2c51d7101 100644 --- a/server/lib/notifier.ts +++ b/server/lib/notifier.ts @@ -13,6 +13,8 @@ import { VideoBlacklistModel } from '../models/video/video-blacklist' import * as Bluebird from 'bluebird' import { VideoImportModel } from '../models/video/video-import' import { AccountBlocklistModel } from '../models/account/account-blocklist' +import { ActorFollowModel } from '../models/activitypub/actor-follow' +import { AccountModel } from '../models/account/account' class Notifier { @@ -38,7 +40,10 @@ class Notifier { notifyOnNewComment (comment: VideoCommentModel): void { 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 { @@ -61,6 +66,23 @@ class Notifier { .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) { // List all followers that are users const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId) @@ -90,6 +112,8 @@ class Notifier { } private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) { + if (comment.Video.isOwned() === false) return + const user = await UserModel.loadByVideoId(comment.videoId) // Not our user or user comments its own video @@ -122,11 +146,100 @@ class Notifier { return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender }) } - private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) { - const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES) + private async notifyOfCommentMention (comment: VideoCommentModel) { + 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 - 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) { return user.NotificationSetting.videoAbuseAsModerator @@ -147,7 +260,7 @@ class Notifier { 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) { @@ -264,6 +377,37 @@ class Notifier { 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: { users: UserModel[], notificationCreator: (user: UserModel) => Promise, diff --git a/server/lib/user.ts b/server/lib/user.ts index 481571828..9e24e85a0 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -10,7 +10,7 @@ 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' +import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) { const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => { @@ -96,13 +96,18 @@ export { // --------------------------------------------------------------------------- function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) { - return UserNotificationSettingModel.create({ + const values: UserNotificationSetting & { userId: number } = { userId: user.id, newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION, newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoImportFinished: UserNotificationSettingValue.WEB_NOTIFICATION, myVideoPublished: UserNotificationSettingValue.WEB_NOTIFICATION, videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL - }, { transaction: t }) + blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB_NOTIFICATION, + commentMention: UserNotificationSettingValue.WEB_NOTIFICATION, + newFollow: UserNotificationSettingValue.WEB_NOTIFICATION + } + + return UserNotificationSettingModel.create(values, { transaction: t }) } diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts index 54ac290c4..efd6ed59e 100644 --- a/server/models/account/account-blocklist.ts +++ b/server/models/account/account-blocklist.ts @@ -2,6 +2,7 @@ import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, Updated import { AccountModel } from './account' import { getSort } from '../utils' import { AccountBlock } from '../../../shared/models/blocklist' +import { Op } from 'sequelize' enum ScopeNames { WITH_ACCOUNTS = 'WITH_ACCOUNTS' @@ -73,18 +74,33 @@ export class AccountBlocklistModel extends Model { BlockedAccount: AccountModel static isAccountMutedBy (accountId: number, targetAccountId: number) { + return AccountBlocklistModel.isAccountMutedByMulti([ accountId ], targetAccountId) + .then(result => result[accountId]) + } + + static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) { const query = { - attributes: [ 'id' ], + attributes: [ 'accountId', 'id' ], where: { - accountId, + accountId: { + [Op.any]: accountIds + }, targetAccountId }, raw: true } return AccountBlocklistModel.unscoped() - .findOne(query) - .then(a => !!a) + .findAll(query) + .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) { diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts index 6470defa7..f1c3ac223 100644 --- a/server/models/account/user-notification-setting.ts +++ b/server/models/account/user-notification-setting.ts @@ -83,6 +83,33 @@ export class UserNotificationSettingModel extends Model 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) @Column userId: number @@ -114,7 +141,10 @@ export class UserNotificationSettingModel extends Model VideoChannelModel.unscoped() } } -function buildAccountInclude () { +function buildAccountInclude (required: boolean) { return { - required: true, + required, attributes: [ 'id', 'name' ], model: () => AccountModel.unscoped() } @@ -58,14 +60,14 @@ function buildAccountInclude () { [ScopeNames.WITH_ALL]: { include: [ Object.assign(buildVideoInclude(false), { - include: [ buildChannelInclude() ] + include: [ buildChannelInclude(true) ] }), { attributes: [ 'id', 'originCommentId' ], model: () => VideoCommentModel.unscoped(), required: false, include: [ - buildAccountInclude(), + buildAccountInclude(true), buildVideoInclude(true) ] }, @@ -86,6 +88,42 @@ function buildAccountInclude () { model: () => VideoImportModel.unscoped(), required: 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 { }) 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) { const query: IFindOptions = { offset: start, @@ -264,6 +326,25 @@ export class UserNotificationModel extends Model { video: this.formatVideo(this.VideoBlacklist.Video) } : 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 { id: this.id, type: this.type, @@ -273,6 +354,8 @@ export class UserNotificationModel extends Model { comment, videoAbuse, videoBlacklist, + account, + actorFollow, createdAt: this.createdAt.toISOString(), updatedAt: this.updatedAt.toISOString() } diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 33f56f641..017a96657 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -330,6 +330,16 @@ export class UserModel extends Model { return UserModel.unscoped().findAll(query) } + static listByUsernames (usernames: string[]) { + const query = { + where: { + username: usernames + } + } + + return UserModel.findAll(query) + } + static loadById (id: number) { return UserModel.findById(id) } @@ -424,6 +434,47 @@ export class UserModel extends Model { 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) { // Don't use sequelize because we need to use a sub query const query = UserModel.generateUserQuotaBaseSQL() diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index d8fc2a564..cf6278da7 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -18,7 +18,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' import { VideoComment } from '../../../shared/models/videos/video-comment.model' 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 { AccountModel } from '../account/account' import { ActorModel } from '../activitypub/actor' @@ -29,6 +29,9 @@ import { VideoModel } from './video' import { VideoChannelModel } from './video-channel' import { getServerActor } from '../../helpers/utils' import { UserModel } from '../account/user' +import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' +import { regexpCapture } from '../../helpers/regexp' +import { uniq } from 'lodash' enum ScopeNames { WITH_ACCOUNT = 'WITH_ACCOUNT', @@ -370,9 +373,11 @@ export class VideoCommentModel extends Model { id: { [ Sequelize.Op.in ]: Sequelize.literal('(' + 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + - 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' + - 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' + - 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' + + `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + + 'UNION ' + + 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' + + 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' + + ') ' + 'SELECT id FROM children' + ')'), [ Sequelize.Op.ne ]: comment.id @@ -460,6 +465,34 @@ export class VideoCommentModel extends Model { 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 () { return { id: this.id, diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts index 4f21f7b95..635f5c9a3 100644 --- a/server/tests/api/check-params/user-notifications.ts +++ b/server/tests/api/check-params/user-notifications.ts @@ -139,7 +139,10 @@ describe('Test user notifications API validators', function () { videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION, blacklistOnMyVideo: 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 () { diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts index e4966dbf5..ae77b4db2 100644 --- a/server/tests/api/users/user-notifications.ts +++ b/server/tests/api/users/user-notifications.ts @@ -10,9 +10,12 @@ import { flushTests, getMyUserInformation, immutableAssign, + registerUser, removeVideoFromBlacklist, reportVideoAbuse, + updateMyUser, updateVideo, + updateVideoChannel, userLogin, wait } from '../../../../shared/utils' @@ -21,16 +24,20 @@ import { setAccessTokensToServers } from '../../../../shared/utils/users/login' import { waitJobs } from '../../../../shared/utils/server/jobs' import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io' import { + checkCommentMention, CheckerBaseParams, + checkMyVideoImportIsFinished, + checkNewActorFollow, checkNewBlacklistOnMyVideo, checkNewCommentOnMyVideo, checkNewVideoAbuseForModerators, checkNewVideoFromSubscription, + checkUserRegistered, + checkVideoIsPublished, getLastNotification, getUserNotifications, markAsReadNotifications, - updateMyNotificationSettings, - checkVideoIsPublished, checkMyVideoImportIsFinished + updateMyNotificationSettings } from '../../../../shared/utils/users/user-notifications' import { User, @@ -40,9 +47,9 @@ import { UserNotificationType } from '../../../../shared/models/users' 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 { 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 * as uuidv4 from 'uuid/v4' import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/utils/users/blocklist' @@ -81,12 +88,15 @@ describe('Test users notifications', function () { let channelId: number 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, + newCommentOnMyVideo: 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 () { @@ -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 () { 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 () { it('Should mark as read some notifications', async function () { const res = await getUserNotifications(servers[ 0 ].url, userAccessToken, 2, 3) diff --git a/server/tests/helpers/comment-model.ts b/server/tests/helpers/comment-model.ts new file mode 100644 index 000000000..76bb0f212 --- /dev/null +++ b/server/tests/helpers/comment-model.ts @@ -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' ]) + }) +}) diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts index 40c7dc70e..551208245 100644 --- a/server/tests/helpers/index.ts +++ b/server/tests/helpers/index.ts @@ -1 +1,2 @@ import './core-utils' +import './comment-model' diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts index 55d351abf..f580e827e 100644 --- a/shared/models/users/user-notification-setting.model.ts +++ b/shared/models/users/user-notification-setting.model.ts @@ -12,4 +12,7 @@ export interface UserNotificationSetting { blacklistOnMyVideo: UserNotificationSettingValue myVideoPublished: UserNotificationSettingValue myVideoImportFinished: UserNotificationSettingValue + newUserRegistration: UserNotificationSettingValue + newFollow: UserNotificationSettingValue + commentMention: UserNotificationSettingValue } diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts index ee9ac275a..9dd4f099f 100644 --- a/shared/models/users/user-notification.model.ts +++ b/shared/models/users/user-notification.model.ts @@ -6,7 +6,10 @@ export enum UserNotificationType { UNBLACKLIST_ON_MY_VIDEO = 5, MY_VIDEO_PUBLISHED = 6, 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 { @@ -55,6 +58,25 @@ export interface UserNotification { 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 updatedAt: string } diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 51c59d20a..090256bca 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -2,10 +2,15 @@ export enum UserRight { ALL, MANAGE_USERS, + MANAGE_SERVER_FOLLOW, + MANAGE_SERVER_REDUNDANCY, + MANAGE_VIDEO_ABUSES, + MANAGE_JOBS, + MANAGE_CONFIGURATION, MANAGE_ACCOUNTS_BLOCKLIST, diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index adef8fd95..59c2ba106 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts @@ -29,7 +29,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = { UserRight.UPDATE_ANY_VIDEO, UserRight.SEE_ALL_VIDEOS, UserRight.MANAGE_ACCOUNTS_BLOCKLIST, - UserRight.MANAGE_SERVERS_BLOCKLIST + UserRight.MANAGE_SERVERS_BLOCKLIST, + UserRight.MANAGE_USERS ], [UserRole.USER]: [] diff --git a/shared/utils/users/user-notifications.ts b/shared/utils/users/user-notifications.ts index 75d52023a..1222899e7 100644 --- a/shared/utils/users/user-notifications.ts +++ b/shared/utils/users/user-notifications.ts @@ -98,9 +98,11 @@ async function checkNotification ( }) 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 { - 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') } -function checkActor (channel: any) { - expect(channel.id).to.be.a('number') - expect(channel.displayName).to.be.a('string') - expect(channel.displayName).to.not.be.empty +function checkActor (actor: any) { + expect(actor.displayName).to.be.a('string') + expect(actor.displayName).to.not.be.empty } function checkComment (comment: any, commentId: number, threadId: number) { @@ -220,6 +221,103 @@ async function checkMyVideoImportIsFinished ( 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 async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) { const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO @@ -312,10 +410,13 @@ export { CheckerType, checkNotification, checkMyVideoImportIsFinished, + checkUserRegistered, checkVideoIsPublished, checkNewVideoFromSubscription, + checkNewActorFollow, checkNewCommentOnMyVideo, checkNewBlacklistOnMyVideo, + checkCommentMention, updateMyNotificationSettings, checkNewVideoAbuseForModerators, getUserNotifications, -- cgit v1.2.3