Add auto follow back support for instances
authorChocobozzz <me@florianbigard.com>
Fri, 30 Aug 2019 14:50:12 +0000 (16:50 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 4 Sep 2019 14:24:58 +0000 (16:24 +0200)
44 files changed:
client/src/app/shared/users/user-notification.model.ts
config/default.yaml
config/production.yaml.example
package.json
server.ts
server/controllers/api/config.ts
server/controllers/api/server/follows.ts
server/controllers/api/users/my-notifications.ts
server/initializers/config.ts
server/lib/activitypub/follow.ts [new file with mode: 0644]
server/lib/activitypub/process/process-accept.ts
server/lib/activitypub/process/process-follow.ts
server/lib/activitypub/send/send-follow.ts
server/lib/emailer.ts
server/lib/job-queue/handlers/activitypub-follow.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/notifier.ts
server/lib/user.ts
server/lib/video-blacklist.ts
server/middlewares/validators/user-notifications.ts
server/models/account/account.ts
server/models/account/user-notification-setting.ts
server/models/account/user-notification.ts
server/models/activitypub/actor.ts
server/models/server/server.ts
server/models/video/video-channel.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/user-notifications.ts
server/tests/api/notifications/user-notifications.ts
server/tests/api/server/auto-follows.ts [new file with mode: 0644]
server/tests/api/server/config.ts
server/tests/api/server/index.ts
server/typings/models/account/actor-follow.ts
server/typings/models/account/actor.ts
server/typings/models/user/user-notification.ts
server/typings/models/video/video-blacklist.ts
server/typings/utils.ts
shared/extra-utils/server/config.ts
shared/extra-utils/users/user-notifications.ts
shared/models/server/custom-config.model.ts
shared/models/users/user-notification-setting.model.ts
shared/models/users/user-notification.model.ts
tsconfig.json
yarn.lock

index 06eace71c94d7ba94dc3e3a3ff788edf91d6a7fc..37fa29ee814e2fdf56fd8136a88f5b9c27c98932 100644 (file)
@@ -112,7 +112,7 @@ export class UserNotification implements UserNotificationServer {
 
         case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
           this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
-          this.videoUrl = this.buildVideoUrl(this.video)
+          this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
           break
 
         case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
index b8ba7006addb8dc17a877b549bb060cc928b44e1..5a935fede090d30471d5c847b1594d7ce4cb5d02 100644 (file)
@@ -273,5 +273,21 @@ followers:
     # Whether or not an administrator must manually validate a new follower
     manual_approval: false
 
+followings:
+  instance:
+    # If you want to automatically follow back new instance followers
+    # Only follows accepted followers (in case you enabled manual followers approbation)
+    # If this option is enabled, use the mute feature instead of deleting followings
+    # /!\ Don't enable this if you don't have a reactive moderation team /!\
+    auto_follow_back:
+      enabled: false
+
+    # If you want to automatically follow instances of the public index
+    # If this option is enabled, use the mute feature instead of deleting followings
+    # /!\ Don't enable this if you don't have a reactive moderation team /!\
+    auto_follow_index:
+      enabled: false
+      index_url: 'https://instances.joinpeertube.org'
+
 theme:
   default: 'default'
index 2dec5ef0ce98026d971098a81d94cc0e348f2dda..397e52740b4c9851a4f74fde1b2807cc43374234 100644 (file)
@@ -288,5 +288,20 @@ followers:
     # Whether or not an administrator must manually validate a new follower
     manual_approval: false
 
+followings:
+  instance:
+    # If you want to automatically follow back new instance followers
+    # If this option is enabled, use the mute feature instead of deleting followings
+    # /!\ Don't enable this if you don't have a reactive moderation team /!\
+    auto_follow_back:
+      enabled: false
+
+    # If you want to automatically follow instances of the public index
+    # If this option is enabled, use the mute feature instead of deleting followings
+    # /!\ Don't enable this if you don't have a reactive moderation team /!\
+    auto_follow_index:
+      enabled: false
+      index_url: 'https://instances.joinpeertube.org'
+
 theme:
   default: 'default'
index 0c1ec93d13e33212392715d4e0b3905616fc1221..38c40bcf0788f48aca8122f8e89162b84d2f3217 100644 (file)
     "lru-cache": "^5.1.1",
     "magnet-uri": "^5.1.4",
     "memoizee": "^0.4.14",
+    "module-alias": "^2.2.1",
     "morgan": "^1.5.3",
     "multer": "^1.1.0",
     "nodemailer": "^6.0.0",
   "scripty": {
     "silent": true
   },
-  "sasslintConfig": "client/.sass-lint.yml"
+  "sasslintConfig": "client/.sass-lint.yml",
+  "_moduleAliases": {
+    "@server": "dist/server"
+  }
 }
index 50511a90684c7ec3599c8818f8ded3509ebd5eb7..d5f8f0b2b872398594694ae5fc71280e4362b1be 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -1,3 +1,5 @@
+require('module-alias/register')
+
 // FIXME: https://github.com/nodejs/node/pull/16853
 import { PluginManager } from './server/lib/plugins/plugin-manager'
 
index 21fa85a085a3ece6acbd41bf74a433f2f0ddfdfd..0c52bfa7a0d074c2652a95b7731722af51ce68ff 100644 (file)
@@ -300,6 +300,18 @@ function customConfig (): CustomConfig {
         enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
         manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
       }
+    },
+    followings: {
+      instance: {
+        autoFollowBack: {
+          enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED
+        },
+
+        autoFollowIndex: {
+          enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.ENABLED,
+          indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
+        }
+      }
     }
   }
 }
index d38ce91debdde0aa5979c8f1a1798447806db784..37647622b6b3c525a98965fdc74c2ecf75d27d5f 100644 (file)
@@ -25,6 +25,7 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { JobQueue } from '../../../lib/job-queue'
 import { removeRedundancyOf } from '../../../lib/redundancy'
 import { sequelizeTypescript } from '../../../initializers/database'
+import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
 
 const serverFollowsRouter = express.Router()
 serverFollowsRouter.get('/following',
@@ -172,5 +173,7 @@ async function acceptFollower (req: express.Request, res: express.Response) {
   follow.state = 'accepted'
   await follow.save()
 
+  await autoFollowBackIfNeeded(follow)
+
   return res.status(204).end()
 }
index f146284e4acc50b7f9c60155533214402b6e7076..017f5219edeb5a3b65bf6bbe855243d7d35aaefb 100644 (file)
@@ -76,7 +76,8 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
     newFollow: body.newFollow,
     newUserRegistration: body.newUserRegistration,
     commentMention: body.commentMention,
-    newInstanceFollower: body.newInstanceFollower
+    newInstanceFollower: body.newInstanceFollower,
+    autoInstanceFollowing: body.autoInstanceFollowing
   }
 
   await UserNotificationSettingModel.update(values, query)
index 510f7d64d34a2c5d24f7a5525dfe7b3c3fbc0c27..599f3f5acd79173e22e65d00e6cec3105ca1bf5b 100644 (file)
@@ -232,6 +232,23 @@ const CONFIG = {
       get MANUAL_APPROVAL () { return config.get<boolean>('followers.instance.manual_approval') }
     }
   },
+  FOLLOWINGS: {
+    INSTANCE: {
+      AUTO_FOLLOW_BACK: {
+        get ENABLED () {
+          return config.get<boolean>('followings.instance.auto_follow_back.enabled')
+        }
+      },
+      AUTO_FOLLOW_INDEX: {
+        get ENABLED () {
+          return config.get<boolean>('followings.instance.auto_follow_index.enabled')
+        },
+        get INDEX_URL () {
+          return config.get<string>('followings.instance.auto_follow_index.index_url')
+        }
+      }
+    }
+  },
   THEME: {
     get DEFAULT () { return config.get<string>('theme.default') }
   }
diff --git a/server/lib/activitypub/follow.ts b/server/lib/activitypub/follow.ts
new file mode 100644 (file)
index 0000000..c57e43c
--- /dev/null
@@ -0,0 +1,36 @@
+import { MActorFollowActors } from '../../typings/models'
+import { CONFIG } from '../../initializers/config'
+import { SERVER_ACTOR_NAME } from '../../initializers/constants'
+import { JobQueue } from '../job-queue'
+import { logger } from '../../helpers/logger'
+import { getServerActor } from '../../helpers/utils'
+import { ServerModel } from '@server/models/server/server'
+
+async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors) {
+  if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return
+
+  const follower = actorFollow.ActorFollower
+
+  if (follower.type === 'Application' && follower.preferredUsername === SERVER_ACTOR_NAME) {
+    logger.info('Auto follow back %s.', follower.url)
+
+    const me = await getServerActor()
+
+    const server = await ServerModel.load(follower.serverId)
+    const host = server.host
+
+    const payload = {
+      host,
+      name: SERVER_ACTOR_NAME,
+      followerActorId: me.id,
+      isAutoFollow: true
+    }
+
+    JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
+            .catch(err => logger.error('Cannot create auto follow back job for %s.', host, err))
+  }
+}
+
+export {
+  autoFollowBackIfNeeded
+}
index 86f7c764db163b93e7b6b3eca3fc0229c2faf2c5..dcfbb2c84a673ab1ca23c170fd6ed920b8608959 100644 (file)
@@ -24,7 +24,7 @@ async function processAccept (actor: MActorDefault, targetActor: MActorSignature
   if (!follow) throw new Error('Cannot find associated follow.')
 
   if (follow.state !== 'accepted') {
-    follow.set('state', 'accepted')
+    follow.state = 'accepted'
     await follow.save()
 
     await addFetchOutboxJob(targetActor)
index bc5660395ae92a78df35544e50fb0b4b7b0394bf..85f22d654ebd9611ac7cfb8a7691e3a3d7adb5e2 100644 (file)
@@ -10,7 +10,8 @@ import { getAPId } from '../../../helpers/activitypub'
 import { getServerActor } from '../../../helpers/utils'
 import { CONFIG } from '../../../initializers/config'
 import { APProcessorOptions } from '../../../typings/activitypub-processor.model'
-import { MAccount, MActorFollowActors, MActorFollowFull, MActorSignature } from '../../../typings/models'
+import { MActorFollowActors, MActorSignature } from '../../../typings/models'
+import { autoFollowBackIfNeeded } from '../follow'
 
 async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
   const { activity, byActor } = options
@@ -28,7 +29,7 @@ export {
 // ---------------------------------------------------------------------------
 
 async function processFollow (byActor: MActorSignature, targetActorURL: string) {
-  const { actorFollow, created, isFollowingInstance } = await sequelizeTypescript.transaction(async t => {
+  const { actorFollow, created, isFollowingInstance, targetActor } = await sequelizeTypescript.transaction(async t => {
     const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
 
     if (!targetActor) throw new Error('Unknown actor')
@@ -67,21 +68,24 @@ async function processFollow (byActor: MActorSignature, targetActorURL: string)
     actorFollow.ActorFollowing = targetActor
 
     // Target sends to actor he accepted the follow request
-    if (actorFollow.state === 'accepted') await sendAccept(actorFollow)
+    if (actorFollow.state === 'accepted') {
+      await sendAccept(actorFollow)
+      await autoFollowBackIfNeeded(actorFollow)
+    }
 
-    return { actorFollow, created, isFollowingInstance }
+    return { actorFollow, created, isFollowingInstance, targetActor }
   })
 
   // Rejected
   if (!actorFollow) return
 
   if (created) {
+    const follower = await ActorModel.loadFull(byActor.id)
+    const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower })
+
     if (isFollowingInstance) {
-      Notifier.Instance.notifyOfNewInstanceFollow(actorFollow)
+      Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull)
     } else {
-      const actorFollowFull = actorFollow as MActorFollowFull
-      actorFollowFull.ActorFollower.Account = await actorFollow.ActorFollower.$get('Account') as MAccount
-
       Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
     }
   }
index 6b17b25da188d7f3d2cafae5d13a021048339b9d..ce400d8fffa4196412e75238a9f2bef5e964aa0c 100644 (file)
@@ -1,5 +1,4 @@
 import { ActivityFollow } from '../../../../shared/models/activitypub'
-import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { getActorFollowActivityPubUrl } from '../url'
 import { unicastTo } from './utils'
 import { logger } from '../../../helpers/logger'
index 76349ef8f0f23753c41b97cb02ccc875df07ae5d..bd3d4f252eb120132bb01dd27cd5cb1655c7b59e 100644 (file)
@@ -6,8 +6,15 @@ import { JobQueue } from './job-queue'
 import { EmailPayload } from './job-queue/handlers/email'
 import { readFileSync } from 'fs-extra'
 import { WEBSERVER } from '../initializers/constants'
-import { MCommentOwnerVideo, MVideo, MVideoAbuseVideo, MVideoAccountLight, MVideoBlacklistVideo } from '../typings/models/video'
-import { MActorFollowActors, MActorFollowFollowingFullFollowerAccount, MUser } from '../typings/models'
+import {
+  MCommentOwnerVideo,
+  MVideo,
+  MVideoAbuseVideo,
+  MVideoAccountLight,
+  MVideoBlacklistLightVideo,
+  MVideoBlacklistVideo
+} from '../typings/models/video'
+import { MActorFollowActors, MActorFollowFull, MUser } from '../typings/models'
 import { MVideoImport, MVideoImportVideo } from '@server/typings/models/video/video-import'
 
 type SendEmailOptions = {
@@ -107,7 +114,7 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  addNewFollowNotification (to: string[], actorFollow: MActorFollowFollowingFullFollowerAccount, followType: 'account' | 'channel') {
+  addNewFollowNotification (to: string[], actorFollow: MActorFollowFull, followType: 'account' | 'channel') {
     const followerName = actorFollow.ActorFollower.Account.getDisplayName()
     const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
 
@@ -144,6 +151,22 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
+  addAutoInstanceFollowingNotification (to: string[], actorFollow: MActorFollowActors) {
+    const text = `Hi dear admin,\n\n` +
+      `Your instance automatically followed a new instance: ${actorFollow.ActorFollowing.url}` +
+      `\n\n` +
+      `Cheers,\n` +
+      `${CONFIG.EMAIL.BODY.SIGNATURE}`
+
+    const emailPayload: EmailPayload = {
+      to,
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Auto instance following',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
   myVideoPublishedNotification (to: string[], video: MVideo) {
     const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
 
@@ -265,9 +288,9 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  addVideoAutoBlacklistModeratorsNotification (to: string[], video: MVideo) {
+  addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) {
     const VIDEO_AUTO_BLACKLIST_URL = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
-    const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
+    const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
 
     const text = `Hi,\n\n` +
       `A recently added video was auto-blacklisted and requires moderator review before publishing.` +
index 5cb55cad6109b7f825927b5780198a1e8243a699..af7c8a8383afe42bcdcadb7e6e56242f1dcb745b 100644 (file)
@@ -10,12 +10,13 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { Notifier } from '../../notifier'
 import { sequelizeTypescript } from '../../../initializers/database'
-import { MAccount, MActor, MActorFollowActors, MActorFollowFull, MActorFull } from '../../../typings/models'
+import { MActor, MActorFollowActors, MActorFull } from '../../../typings/models'
 
 export type ActivitypubFollowPayload = {
   followerActorId: number
   name: string
   host: string
+  isAutoFollow?: boolean
 }
 
 async function processActivityPubFollow (job: Bull.Job) {
@@ -35,7 +36,7 @@ async function processActivityPubFollow (job: Bull.Job) {
 
   const fromActor = await ActorModel.load(payload.followerActorId)
 
-  return retryTransactionWrapper(follow, fromActor, targetActor)
+  return retryTransactionWrapper(follow, fromActor, targetActor, payload.isAutoFollow)
 }
 // ---------------------------------------------------------------------------
 
@@ -45,7 +46,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function follow (fromActor: MActor, targetActor: MActorFull) {
+async function follow (fromActor: MActor, targetActor: MActorFull, isAutoFollow = false) {
   if (fromActor.id === targetActor.id) {
     throw new Error('Follower is the same than target actor.')
   }
@@ -75,14 +76,15 @@ async function follow (fromActor: MActor, targetActor: MActorFull) {
     return actorFollow
   })
 
-  if (actorFollow.state === 'accepted') {
-    const followerFull = Object.assign(fromActor, { Account: await actorFollow.ActorFollower.$get('Account') as MAccount })
+  const followerFull = await ActorModel.loadFull(fromActor.id)
 
-    const actorFollowFull = Object.assign(actorFollow, {
-      ActorFollowing: targetActor,
-      ActorFollower: followerFull
-    })
+  const actorFollowFull = Object.assign(actorFollow, {
+    ActorFollowing: targetActor,
+    ActorFollower: followerFull
+  })
 
-    Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
-  }
+  if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
+  if (isAutoFollow === true) Notifier.Instance.notifyOfAutoInstanceFollowing(actorFollowFull)
+
+  return actorFollow
 }
index ff8c9332842a0791993fffb7b7ba83ccee86a0ca..93a3e9d901cc0f2819738f4b75728a33f60b3d03 100644 (file)
@@ -21,6 +21,7 @@ import { createVideoMiniatureFromUrl, generateVideoMiniature } from '../../thumb
 import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
 import { MThumbnail } from '../../../typings/models/video/thumbnail'
 import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/typings/models/video/video-import'
+import { MVideoBlacklistVideo, MVideoBlacklist } from '@server/typings/models'
 
 type VideoImportYoutubeDLPayload = {
   type: 'youtube-dl'
@@ -204,7 +205,9 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
     Notifier.Instance.notifyOnFinishedVideoImport(videoImportUpdated, true)
 
     if (video.isBlacklisted()) {
-      Notifier.Instance.notifyOnVideoAutoBlacklist(video)
+      const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video })
+
+      Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist)
     } else {
       Notifier.Instance.notifyOnNewVideoIfNeeded(video)
     }
index 23f76a21a413684fda8cc3cd2d4fffb2d6d497cb..b7cc2607d3160370f36eb5c29192b87027c7506a 100644 (file)
@@ -1,30 +1,30 @@
 import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
 import { logger } from '../helpers/logger'
-import { VideoModel } from '../models/video/video'
 import { Emailer } from './emailer'
 import { UserNotificationModel } from '../models/account/user-notification'
-import { VideoCommentModel } from '../models/video/video-comment'
 import { UserModel } from '../models/account/user'
 import { PeerTubeSocket } from './peertube-socket'
 import { CONFIG } from '../initializers/config'
 import { VideoPrivacy, VideoState } from '../../shared/models/videos'
-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 {
   MCommentOwnerVideo,
-  MVideo,
   MVideoAbuseVideo,
   MVideoAccountLight,
+  MVideoBlacklistLightVideo,
   MVideoBlacklistVideo,
   MVideoFullLight
 } from '../typings/models/video'
-import { MUser, MUserAccount, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/typings/models/user'
-import { MActorFollowActors, MActorFollowFull, MActorFollowFollowingFullFollowerAccount } from '../typings/models'
-import { ActorFollowModel } from '../models/activitypub/actor-follow'
+import {
+  MUser,
+  MUserDefault,
+  MUserNotifSettingAccount,
+  MUserWithNotificationSetting,
+  UserNotificationModelForApi
+} from '@server/typings/models/user'
+import { MActorFollowFull } from '../typings/models'
 import { MVideoImportVideo } from '@server/typings/models/video/video-import'
-import { AccountModel } from '@server/models/account/account'
 
 class Notifier {
 
@@ -77,9 +77,9 @@ class Notifier {
       .catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err }))
   }
 
-  notifyOnVideoAutoBlacklist (video: MVideo): void {
-    this.notifyModeratorsOfVideoAutoBlacklist(video)
-      .catch(err => logger.error('Cannot notify of auto-blacklist of video %s.', video.url, { err }))
+  notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
+    this.notifyModeratorsOfVideoAutoBlacklist(videoBlacklist)
+      .catch(err => logger.error('Cannot notify of auto-blacklist of video %s.', videoBlacklist.Video.url, { err }))
   }
 
   notifyOnVideoBlacklist (videoBlacklist: MVideoBlacklistVideo): void {
@@ -87,7 +87,7 @@ class Notifier {
       .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
   }
 
-  notifyOnVideoUnblacklist (video: MVideo): void {
+  notifyOnVideoUnblacklist (video: MVideoFullLight): void {
     this.notifyVideoOwnerOfUnblacklist(video)
         .catch(err => logger.error('Cannot notify video owner of unblacklist of %s.', video.url, { err }))
   }
@@ -97,12 +97,12 @@ class Notifier {
       .catch(err => logger.error('Cannot notify owner that its video import %s is finished.', videoImport.getTargetIdentifier(), { err }))
   }
 
-  notifyOnNewUserRegistration (user: MUserAccount): void {
+  notifyOnNewUserRegistration (user: MUserDefault): void {
     this.notifyModeratorsOfNewUserRegistration(user)
         .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
   }
 
-  notifyOfNewUserFollow (actorFollow: MActorFollowFollowingFullFollowerAccount): void {
+  notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
     this.notifyUserOfNewActorFollow(actorFollow)
       .catch(err => {
         logger.error(
@@ -114,30 +114,37 @@ class Notifier {
       })
   }
 
-  notifyOfNewInstanceFollow (actorFollow: MActorFollowActors): void {
+  notifyOfNewInstanceFollow (actorFollow: MActorFollowFull): void {
     this.notifyAdminsOfNewInstanceFollow(actorFollow)
         .catch(err => {
           logger.error('Cannot notify administrators of new follower %s.', actorFollow.ActorFollower.url, { err })
         })
   }
 
+  notifyOfAutoInstanceFollowing (actorFollow: MActorFollowFull): void {
+    this.notifyAdminsOfAutoInstanceFollowing(actorFollow)
+        .catch(err => {
+          logger.error('Cannot notify administrators of auto instance following %s.', actorFollow.ActorFollowing.url, { err })
+        })
+  }
+
   private async notifySubscribersOfNewVideo (video: MVideoAccountLight) {
     // List all followers that are users
     const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
 
     logger.info('Notifying %d users of new video %s.', users.length, video.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.newVideoFromSubscription
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
         userId: user.id,
         videoId: video.id
       })
-      notification.Video = video as VideoModel
+      notification.Video = video
 
       return notification
     }
@@ -162,17 +169,17 @@ class Notifier {
 
     logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.newCommentOnMyVideo
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
         userId: user.id,
         commentId: comment.id
       })
-      notification.Comment = comment as VideoCommentModel
+      notification.Comment = comment
 
       return notification
     }
@@ -207,19 +214,19 @@ class Notifier {
 
     logger.info('Notifying %d users of new comment %s.', users.length, comment.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserNotifSettingAccount) {
       if (accountMutedHash[user.Account.id] === true) return UserNotificationSettingValue.NONE
 
       return user.NotificationSetting.commentMention
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserNotifSettingAccount) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.COMMENT_MENTION,
         userId: user.id,
         commentId: comment.id
       })
-      notification.Comment = comment as VideoCommentModel
+      notification.Comment = comment
 
       return notification
     }
@@ -231,7 +238,7 @@ class Notifier {
     return this.notify({ users, settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyUserOfNewActorFollow (actorFollow: MActorFollowFollowingFullFollowerAccount) {
+  private async notifyUserOfNewActorFollow (actorFollow: MActorFollowFull) {
     if (actorFollow.ActorFollowing.isOwned() === false) return
 
     // Account follows one of our account?
@@ -253,17 +260,17 @@ class Notifier {
 
     logger.info('Notifying user %s of new follower: %s.', user.username, followerAccount.getDisplayName())
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.newFollow
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_FOLLOW,
         userId: user.id,
         actorFollowId: actorFollow.id
       })
-      notification.ActorFollow = actorFollow as ActorFollowModel
+      notification.ActorFollow = actorFollow
 
       return notification
     }
@@ -275,22 +282,22 @@ class Notifier {
     return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyAdminsOfNewInstanceFollow (actorFollow: MActorFollowActors) {
+  private async notifyAdminsOfNewInstanceFollow (actorFollow: MActorFollowFull) {
     const admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
 
     logger.info('Notifying %d administrators of new instance follower: %s.', admins.length, actorFollow.ActorFollower.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.newInstanceFollower
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_INSTANCE_FOLLOWER,
         userId: user.id,
         actorFollowId: actorFollow.id
       })
-      notification.ActorFollow = actorFollow as ActorFollowModel
+      notification.ActorFollow = actorFollow
 
       return notification
     }
@@ -302,18 +309,45 @@ class Notifier {
     return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
   }
 
+  private async notifyAdminsOfAutoInstanceFollowing (actorFollow: MActorFollowFull) {
+    const admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
+
+    logger.info('Notifying %d administrators of auto instance following: %s.', admins.length, actorFollow.ActorFollowing.url)
+
+    function settingGetter (user: MUserWithNotificationSetting) {
+      return user.NotificationSetting.autoInstanceFollowing
+    }
+
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
+        type: UserNotificationType.AUTO_INSTANCE_FOLLOWING,
+        userId: user.id,
+        actorFollowId: actorFollow.id
+      })
+      notification.ActorFollow = actorFollow
+
+      return notification
+    }
+
+    function emailSender (emails: string[]) {
+      return Emailer.Instance.addAutoInstanceFollowingNotification(emails, actorFollow)
+    }
+
+    return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
+  }
+
   private async notifyModeratorsOfNewVideoAbuse (videoAbuse: MVideoAbuseVideo) {
     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: MUserWithNotificationSetting) {
       return user.NotificationSetting.videoAbuseAsModerator
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification: UserNotificationModelForApi = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
         userId: user.id,
         videoAbuseId: videoAbuse.id
@@ -330,29 +364,29 @@ class Notifier {
     return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyModeratorsOfVideoAutoBlacklist (video: MVideo) {
+  private async notifyModeratorsOfVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo) {
     const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_BLACKLIST)
     if (moderators.length === 0) return
 
-    logger.info('Notifying %s moderators of video auto-blacklist %s.', moderators.length, video.url)
+    logger.info('Notifying %s moderators of video auto-blacklist %s.', moderators.length, videoBlacklist.Video.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.videoAutoBlacklistAsModerator
     }
-    async function notificationCreator (user: UserModel) {
 
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS,
         userId: user.id,
-        videoId: video.id
+        videoBlacklistId: videoBlacklist.id
       })
-      notification.Video = video as VideoModel
+      notification.VideoBlacklist = videoBlacklist
 
       return notification
     }
 
     function emailSender (emails: string[]) {
-      return Emailer.Instance.addVideoAutoBlacklistModeratorsNotification(emails, video)
+      return Emailer.Instance.addVideoAutoBlacklistModeratorsNotification(emails, videoBlacklist)
     }
 
     return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
@@ -364,17 +398,17 @@ class Notifier {
 
     logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.blacklistOnMyVideo
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
         userId: user.id,
         videoBlacklistId: videoBlacklist.id
       })
-      notification.VideoBlacklist = videoBlacklist as VideoBlacklistModel
+      notification.VideoBlacklist = videoBlacklist
 
       return notification
     }
@@ -386,23 +420,23 @@ class Notifier {
     return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyVideoOwnerOfUnblacklist (video: MVideo) {
+  private async notifyVideoOwnerOfUnblacklist (video: MVideoFullLight) {
     const user = await UserModel.loadByVideoId(video.id)
     if (!user) return
 
     logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.blacklistOnMyVideo
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
         userId: user.id,
         videoId: video.id
       })
-      notification.Video = video as VideoModel
+      notification.Video = video
 
       return notification
     }
@@ -420,17 +454,17 @@ class Notifier {
 
     logger.info('Notifying user %s of the publication of its video %s.', user.username, video.url)
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.myVideoPublished
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.MY_VIDEO_PUBLISHED,
         userId: user.id,
         videoId: video.id
       })
-      notification.Video = video as VideoModel
+      notification.Video = video
 
       return notification
     }
@@ -448,17 +482,17 @@ class Notifier {
 
     logger.info('Notifying user %s its video import %s is finished.', user.username, videoImport.getTargetIdentifier())
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.myVideoImportFinished
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR,
         userId: user.id,
         videoImportId: videoImport.id
       })
-      notification.VideoImport = videoImport as VideoImportModel
+      notification.VideoImport = videoImport
 
       return notification
     }
@@ -472,7 +506,7 @@ class Notifier {
     return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyModeratorsOfNewUserRegistration (registeredUser: MUserAccount) {
+  private async notifyModeratorsOfNewUserRegistration (registeredUser: MUserDefault) {
     const moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
     if (moderators.length === 0) return
 
@@ -481,17 +515,17 @@ class Notifier {
       moderators.length, registeredUser.username
     )
 
-    function settingGetter (user: UserModel) {
+    function settingGetter (user: MUserWithNotificationSetting) {
       return user.NotificationSetting.newUserRegistration
     }
 
-    async function notificationCreator (user: UserModel) {
-      const notification = await UserNotificationModel.create({
+    async function notificationCreator (user: MUserWithNotificationSetting) {
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
         type: UserNotificationType.NEW_USER_REGISTRATION,
         userId: user.id,
         accountId: registeredUser.Account.id
       })
-      notification.Account = registeredUser.Account as AccountModel
+      notification.Account = registeredUser.Account
 
       return notification
     }
@@ -503,11 +537,11 @@ class Notifier {
     return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
   }
 
-  private async notify (options: {
-    users: MUserWithNotificationSetting[],
-    notificationCreator: (user: MUserWithNotificationSetting) => Promise<UserNotificationModelForApi>,
+  private async notify <T extends MUserWithNotificationSetting> (options: {
+    users: T[],
+    notificationCreator: (user: T) => Promise<UserNotificationModelForApi>,
     emailSender: (emails: string[]) => Promise<any> | Bluebird<any>,
-    settingGetter: (user: MUserWithNotificationSetting) => UserNotificationSettingValue
+    settingGetter: (user: T) => UserNotificationSettingValue
   }) {
     const emails: string[] = []
 
index d84aff464b268684bb63620d13292c2ac1963fa9..c45438d95b0784ec96952df92a2974f5ae908be9 100644 (file)
@@ -138,7 +138,8 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
     newUserRegistration: UserNotificationSettingValue.WEB,
     commentMention: UserNotificationSettingValue.WEB,
     newFollow: UserNotificationSettingValue.WEB,
-    newInstanceFollower: UserNotificationSettingValue.WEB
+    newInstanceFollower: UserNotificationSettingValue.WEB,
+    autoInstanceFollowing: UserNotificationSettingValue.WEB
   }
 
   return UserNotificationSettingModel.create(values, { transaction: t })
index a0fc26e84a4ad2bca309db8f1c65d90ff8e390af..1dd45b76d9d1fe742c52d4beef611aa991f357e6 100644 (file)
@@ -6,7 +6,7 @@ import { logger } from '../helpers/logger'
 import { UserAdminFlag } from '../../shared/models/users/user-flag.model'
 import { Hooks } from './plugins/hooks'
 import { Notifier } from './notifier'
-import { MUser, MVideoBlacklist, MVideoWithBlacklistLight } from '@server/typings/models'
+import { MUser, MVideoBlacklistVideo, MVideoWithBlacklistLight } from '@server/typings/models'
 
 async function autoBlacklistVideoIfNeeded (parameters: {
   video: MVideoWithBlacklistLight,
@@ -31,7 +31,7 @@ async function autoBlacklistVideoIfNeeded (parameters: {
     reason: 'Auto-blacklisted. Moderator review required.',
     type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED
   }
-  const [ videoBlacklist ] = await VideoBlacklistModel.findOrCreate<MVideoBlacklist>({
+  const [ videoBlacklist ] = await VideoBlacklistModel.findOrCreate<MVideoBlacklistVideo>({
     where: {
       videoId: video.id
     },
@@ -40,7 +40,9 @@ async function autoBlacklistVideoIfNeeded (parameters: {
   })
   video.VideoBlacklist = videoBlacklist
 
-  if (notify) Notifier.Instance.notifyOnVideoAutoBlacklist(video)
+  videoBlacklist.Video = video
+
+  if (notify) Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist)
 
   logger.info('Video %s auto-blacklisted.', video.uuid)
 
index 308b326552c552ec9f105b9c5f35f7831efc66c1..fbfcb0a4ca0a0399d3235f74d7fc7ab2865a44b7 100644 (file)
@@ -43,6 +43,8 @@ const updateNotificationSettingsValidator = [
     .custom(isUserNotificationSettingValid).withMessage('Should have a valid new user registration notification setting'),
   body('newInstanceFollower')
     .custom(isUserNotificationSettingValid).withMessage('Should have a valid new instance follower notification setting'),
+  body('autoInstanceFollowing')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new instance following notification setting'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking updateNotificationSettingsValidator parameters', { parameters: req.body })
index 394a55f5e087b0128e1cb39f0940329213633aae..ba1094536f9721e4e6c3e0a248ffc84bf848e016 100644 (file)
@@ -381,7 +381,7 @@ export class AccountModel extends Model<AccountModel> {
   }
 
   toActivityPubObject (this: MAccountAP) {
-    const obj = this.Actor.toActivityPubObject(this.name, 'Account')
+    const obj = this.Actor.toActivityPubObject(this.name)
 
     return Object.assign(obj, {
       summary: this.description
index 1506295cf7f7183ad9e6b2c29fac9bb32e86fd9d..dc69a17fda585fa61e0aa329d30a2b326be02046 100644 (file)
@@ -111,6 +111,15 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
   @Column
   newInstanceFollower: UserNotificationSettingValue
 
+  @AllowNull(false)
+  @Default(null)
+  @Is(
+    'UserNotificationSettingNewInstanceFollower',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'autoInstanceFollowing')
+  )
+  @Column
+  autoInstanceFollowing: UserNotificationSettingValue
+
   @AllowNull(false)
   @Default(null)
   @Is(
@@ -165,7 +174,8 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
       newUserRegistration: this.newUserRegistration,
       commentMention: this.commentMention,
       newFollow: this.newFollow,
-      newInstanceFollower: this.newInstanceFollower
+      newInstanceFollower: this.newInstanceFollower,
+      autoInstanceFollowing: this.autoInstanceFollowing
     }
   }
 }
index 9b13a83763eb572f688665429c639b1d2003abbb..ccb81b891f68f77a141a314335addc3e3b60fe59 100644 (file)
@@ -135,13 +135,18 @@ function buildAccountInclude (required: boolean, withActor = false) {
             ]
           },
           {
-            attributes: [ 'preferredUsername' ],
+            attributes: [ 'preferredUsername', 'type' ],
             model: ActorModel.unscoped(),
             required: true,
             as: 'ActorFollowing',
             include: [
               buildChannelInclude(false),
-              buildAccountInclude(false)
+              buildAccountInclude(false),
+              {
+                attributes: [ 'host' ],
+                model: ServerModel.unscoped(),
+                required: false
+              }
             ]
           }
         ]
@@ -404,6 +409,11 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
 
     const account = this.Account ? this.formatActor(this.Account) : undefined
 
+    const actorFollowingType = {
+      Application: 'instance' as 'instance',
+      Group: 'channel' as 'channel',
+      Person: 'account' as 'account'
+    }
     const actorFollow = this.ActorFollow ? {
       id: this.ActorFollow.id,
       state: this.ActorFollow.state,
@@ -415,9 +425,10 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
         host: this.ActorFollow.ActorFollower.getHost()
       },
       following: {
-        type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
+        type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
         displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
-        name: this.ActorFollow.ActorFollowing.preferredUsername
+        name: this.ActorFollow.ActorFollowing.preferredUsername,
+        host: this.ActorFollow.ActorFollowing.getHost()
       }
     } : undefined
 
index 67a1b5bc1b55b9607da7f29708123cdab31f6260..05de1905d57a1eb6401bb98e498660089adfb73f 100644 (file)
@@ -43,7 +43,6 @@ import {
   MActorFormattable,
   MActorFull,
   MActorHost,
-  MActorRedundancyAllowedOpt,
   MActorServer,
   MActorSummaryFormattable
 } from '../../typings/models'
@@ -430,15 +429,8 @@ export class ActorModel extends Model<ActorModel> {
     })
   }
 
-  toActivityPubObject (this: MActorAP, name: string, type: 'Account' | 'Application' | 'VideoChannel') {
+  toActivityPubObject (this: MActorAP, name: string) {
     let activityPubType
-    if (type === 'Account') {
-      activityPubType = 'Person' as 'Person'
-    } else if (type === 'Application') {
-      activityPubType = 'Application' as 'Application'
-    } else { // VideoChannel
-      activityPubType = 'Group' as 'Group'
-    }
 
     let icon = undefined
     if (this.avatarId) {
@@ -451,7 +443,7 @@ export class ActorModel extends Model<ActorModel> {
     }
 
     const json = {
-      type: activityPubType,
+      type: this.type,
       id: this.url,
       following: this.getFollowingUrl(),
       followers: this.getFollowersUrl(),
index 3b6759b5c44609e5b65b3413db46f789058e0242..8b07115f1f810a43c0403f7ef7b75b3702ff64a0 100644 (file)
@@ -51,6 +51,16 @@ export class ServerModel extends Model<ServerModel> {
   })
   BlockedByAccounts: ServerBlocklistModel[]
 
+  static load (id: number): Bluebird<MServer> {
+    const query = {
+      where: {
+        id
+      }
+    }
+
+    return ServerModel.findOne(query)
+  }
+
   static loadByHost (host: string): Bluebird<MServer> {
     const query = {
       where: {
index 7178631b47119ee3efc251255b11fcda5bdaa4bc..05545bd9d6f9d6e925d0b4f7a1f78065a2a6d6f7 100644 (file)
@@ -517,7 +517,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
   }
 
   toActivityPubObject (this: MChannelAP): ActivityPubActor {
-    const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
+    const obj = this.Actor.toActivityPubObject(this.name)
 
     return Object.assign(obj, {
       summary: this.description,
index 7773ae1e7db7a6cf5d8ae7b6d6a8c740f1bd3a21..1221735c5991224df1d400966d63ee46ba1c33e6 100644 (file)
@@ -5,8 +5,16 @@ import 'mocha'
 import { CustomConfig } from '../../../../shared/models/server/custom-config.model'
 
 import {
-  createUser, flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePutBodyRequest, flushAndRunServer, ServerInfo,
-  setAccessTokensToServers, userLogin, immutableAssign, cleanupTests
+  cleanupTests,
+  createUser,
+  flushAndRunServer,
+  immutableAssign,
+  makeDeleteRequest,
+  makeGetRequest,
+  makePutBodyRequest,
+  ServerInfo,
+  setAccessTokensToServers,
+  userLogin
 } from '../../../../shared/extra-utils'
 
 describe('Test config API validators', function () {
@@ -98,6 +106,17 @@ describe('Test config API validators', function () {
         enabled: false,
         manualApproval: true
       }
+    },
+    followings: {
+      instance: {
+        autoFollowBack: {
+          enabled: true
+        },
+        autoFollowIndex: {
+          enabled: true,
+          indexUrl: 'https://index.example.com'
+        }
+      }
     }
   }
 
index 14ee20d451128076e84f8bc35cc6f588eb9e8d03..3b06be7ef496281b29f6dbfd910429d089cca7d3 100644 (file)
@@ -172,7 +172,8 @@ describe('Test user notifications API validators', function () {
       commentMention: UserNotificationSettingValue.WEB,
       newFollow: UserNotificationSettingValue.WEB,
       newUserRegistration: UserNotificationSettingValue.WEB,
-      newInstanceFollower: UserNotificationSettingValue.WEB
+      newInstanceFollower: UserNotificationSettingValue.WEB,
+      autoInstanceFollowing: UserNotificationSettingValue.WEB
     }
 
     it('Should fail with missing fields', async function () {
index 6fa6305623486d72b2090bea6d34584afaced017..62b797b47e83f02c0551904d65e96392d89601b3 100644 (file)
@@ -16,8 +16,8 @@ import {
   immutableAssign,
   registerUser,
   removeVideoFromBlacklist,
-  reportVideoAbuse,
-  updateCustomConfig,
+  reportVideoAbuse, unfollow,
+  updateCustomConfig, updateCustomSubConfig,
   updateMyUser,
   updateVideo,
   updateVideoChannel,
@@ -45,7 +45,8 @@ import {
   getUserNotifications,
   markAsReadAllNotifications,
   markAsReadNotifications,
-  updateMyNotificationSettings
+  updateMyNotificationSettings,
+  checkAutoInstanceFollowing
 } from '../../../../shared/extra-utils/users/user-notifications'
 import {
   User,
@@ -108,7 +109,8 @@ describe('Test users notifications', function () {
     commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
-    newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
+    newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+    autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
   }
 
   before(async function () {
@@ -897,6 +899,36 @@ describe('Test users notifications', function () {
       const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
       await checkNewInstanceFollower(immutableAssign(baseParams, userOverride), 'localhost:' + servers[2].port, 'absence')
     })
+
+    it('Should send a notification on auto follow back', async function () {
+      this.timeout(40000)
+
+      await unfollow(servers[2].url, servers[2].accessToken, servers[0])
+      await waitJobs(servers)
+
+      const config = {
+        followings: {
+          instance: {
+            autoFollowBack: { enabled: true }
+          }
+        }
+      }
+      await updateCustomSubConfig(servers[0].url, servers[0].accessToken, config)
+
+      await follow(servers[2].url, [ servers[0].url ], servers[2].accessToken)
+
+      await waitJobs(servers)
+
+      const followerHost = servers[0].host
+      const followingHost = servers[2].host
+      await checkAutoInstanceFollowing(baseParams, followerHost, followingHost, 'presence')
+
+      const userOverride = { socketNotifications: userNotifications, token: userAccessToken, check: { web: true, mail: false } }
+      await checkAutoInstanceFollowing(immutableAssign(baseParams, userOverride), followerHost, followingHost, 'absence')
+
+      config.followings.instance.autoFollowBack.enabled = false
+      await updateCustomSubConfig(servers[0].url, servers[0].accessToken, config)
+    })
   })
 
   describe('New actor follow', function () {
diff --git a/server/tests/api/server/auto-follows.ts b/server/tests/api/server/auto-follows.ts
new file mode 100644 (file)
index 0000000..32ad259
--- /dev/null
@@ -0,0 +1,148 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  acceptFollower,
+  cleanupTests,
+  flushAndRunMultipleServers,
+  ServerInfo,
+  setAccessTokensToServers,
+  unfollow,
+  updateCustomSubConfig
+} from '../../../../shared/extra-utils/index'
+import { follow, getFollowersListPaginationAndSort, getFollowingListPaginationAndSort } from '../../../../shared/extra-utils/server/follows'
+import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
+import { ActorFollow } from '../../../../shared/models/actors'
+
+const expect = chai.expect
+
+async function checkFollow (follower: ServerInfo, following: ServerInfo, exists: boolean) {
+  {
+    const res = await getFollowersListPaginationAndSort(following.url, 0, 5, '-createdAt')
+    const follows = res.body.data as ActorFollow[]
+
+    if (exists === true) {
+      expect(res.body.total).to.equal(1)
+
+      expect(follows[ 0 ].follower.host).to.equal(follower.host)
+      expect(follows[ 0 ].state).to.equal('accepted')
+    } else {
+      expect(follows.filter(f => f.state === 'accepted')).to.have.lengthOf(0)
+    }
+  }
+
+  {
+    const res = await getFollowingListPaginationAndSort(follower.url, 0, 5, '-createdAt')
+    const follows = res.body.data as ActorFollow[]
+
+    if (exists === true) {
+      expect(res.body.total).to.equal(1)
+
+      expect(follows[ 0 ].following.host).to.equal(following.host)
+      expect(follows[ 0 ].state).to.equal('accepted')
+    } else {
+      expect(follows.filter(f => f.state === 'accepted')).to.have.lengthOf(0)
+    }
+  }
+}
+
+async function server1Follows2 (servers: ServerInfo[]) {
+  await follow(servers[0].url, [ servers[1].host ], servers[0].accessToken)
+
+  await waitJobs(servers)
+}
+
+async function resetFollows (servers: ServerInfo[]) {
+  try {
+    await unfollow(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ])
+    await unfollow(servers[ 1 ].url, servers[ 1 ].accessToken, servers[ 0 ])
+  } catch { /* empty */ }
+
+  await waitJobs(servers)
+
+  await checkFollow(servers[0], servers[1], false)
+  await checkFollow(servers[1], servers[0], false)
+}
+
+describe('Test auto follows', function () {
+  let servers: ServerInfo[] = []
+
+  before(async function () {
+    this.timeout(30000)
+
+    servers = await flushAndRunMultipleServers(2)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+  })
+
+  describe('Auto follow back', function () {
+
+    it('Should not auto follow back if the option is not enabled', async function () {
+      this.timeout(15000)
+
+      await server1Follows2(servers)
+
+      await checkFollow(servers[0], servers[1], true)
+      await checkFollow(servers[1], servers[0], false)
+
+      await resetFollows(servers)
+    })
+
+    it('Should auto follow back on auto accept if the option is enabled', async function () {
+      this.timeout(15000)
+
+      const config = {
+        followings: {
+          instance: {
+            autoFollowBack: { enabled: true }
+          }
+        }
+      }
+      await updateCustomSubConfig(servers[1].url, servers[1].accessToken, config)
+
+      await server1Follows2(servers)
+
+      await checkFollow(servers[0], servers[1], true)
+      await checkFollow(servers[1], servers[0], true)
+
+      await resetFollows(servers)
+    })
+
+    it('Should wait the acceptation before auto follow back', async function () {
+      this.timeout(30000)
+
+      const config = {
+        followings: {
+          instance: {
+            autoFollowBack: { enabled: true }
+          }
+        },
+        followers: {
+          instance: {
+            manualApproval: true
+          }
+        }
+      }
+      await updateCustomSubConfig(servers[1].url, servers[1].accessToken, config)
+
+      await server1Follows2(servers)
+
+      await checkFollow(servers[0], servers[1], false)
+      await checkFollow(servers[1], servers[0], false)
+
+      await acceptFollower(servers[1].url, servers[1].accessToken, 'peertube@' + servers[0].host)
+      await waitJobs(servers)
+
+      await checkFollow(servers[0], servers[1], true)
+      await checkFollow(servers[1], servers[0], true)
+
+      await resetFollows(servers)
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
index 78fdc9cc09479faa98919e8cb3ad7b13c3881d5b..b2f1933d10baa5429c8fec51f10147d36b75e50b 100644 (file)
@@ -68,6 +68,10 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
 
   expect(data.followers.instance.enabled).to.be.true
   expect(data.followers.instance.manualApproval).to.be.false
+
+  expect(data.followings.instance.autoFollowBack.enabled).to.be.false
+  expect(data.followings.instance.autoFollowIndex.enabled).to.be.false
+  expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://instances.joinpeertube.org')
 }
 
 function checkUpdatedConfig (data: CustomConfig) {
@@ -119,6 +123,10 @@ function checkUpdatedConfig (data: CustomConfig) {
 
   expect(data.followers.instance.enabled).to.be.false
   expect(data.followers.instance.manualApproval).to.be.true
+
+  expect(data.followings.instance.autoFollowBack.enabled).to.be.true
+  expect(data.followings.instance.autoFollowIndex.enabled).to.be.true
+  expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://updated.example.com')
 }
 
 describe('Test config', function () {
@@ -261,6 +269,17 @@ describe('Test config', function () {
           enabled: false,
           manualApproval: true
         }
+      },
+      followings: {
+        instance: {
+          autoFollowBack: {
+            enabled: true
+          },
+          autoFollowIndex: {
+            enabled: true,
+            indexUrl: 'https://updated.example.com'
+          }
+        }
       }
     }
     await updateCustomConfig(server.url, server.accessToken, newCustomConfig)
index 3daeeb49aa971a581b23acba73645952f45fcb93..08205b2c831b79eeaedf96af7a63c7f60781ac71 100644 (file)
@@ -1,3 +1,4 @@
+import './auto-follows'
 import './config'
 import './contact-form'
 import './email'
index 17a47b8df781246c9c8d35af95e6981dacf33d47..1c66eb0a00336f895712f706550023981841286c 100644 (file)
@@ -2,7 +2,7 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import {
   MActor,
   MActorAccount,
-  MActorAccountChannel,
+  MActorDefaultAccountChannel,
   MActorChannelAccountActor,
   MActorDefault,
   MActorFormattable,
@@ -37,8 +37,8 @@ export type MActorFollowActorsDefault = MActorFollow &
   Use<'ActorFollowing', MActorDefault>
 
 export type MActorFollowFull = MActorFollow &
-  Use<'ActorFollower', MActorAccountChannel> &
-  Use<'ActorFollowing', MActorAccountChannel>
+  Use<'ActorFollower', MActorDefaultAccountChannel> &
+  Use<'ActorFollowing', MActorDefaultAccountChannel>
 
 // ############################################################################
 
@@ -51,10 +51,6 @@ export type MActorFollowActorsDefaultSubscription = MActorFollow &
   Use<'ActorFollower', MActorDefault> &
   Use<'ActorFollowing', SubscriptionFollowing>
 
-export type MActorFollowFollowingFullFollowerAccount = MActorFollow &
-  Use<'ActorFollower', MActorAccount> &
-  Use<'ActorFollowing', MActorAccountChannel>
-
 export type MActorFollowSubscriptions = MActorFollow &
   Use<'ActorFollowing', MActorChannelAccountActor>
 
index d4bcac4a372869e189f569a8369f8e5b2c15dade..bcacb83516680d4c1107573aa0bef8d8e72f4a14 100644 (file)
@@ -58,7 +58,7 @@ export type MActorAccount = MActor &
 export type MActorChannel = MActor &
   Use<'VideoChannel', MChannel>
 
-export type MActorAccountChannel = MActorAccount & MActorChannel
+export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
 
 export type MActorServer = MActor &
   Use<'Server', MServer>
index f9daf5eb29cd674b48c1e9a79ec7111af3933349..1cdc691b06b0a819929cc18476e156ae85bb99a6 100644 (file)
@@ -1,5 +1,5 @@
 import { UserNotificationModel } from '../../../models/account/user-notification'
-import { PickWith } from '../../utils'
+import { PickWith, PickWithOpt } from '../../utils'
 import { VideoModel } from '../../../models/video/video'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { ServerModel } from '../../../models/server/server'
@@ -48,12 +48,13 @@ export namespace UserNotificationIncludes {
 
   export type ActorFollower = Pick<ActorModel, 'preferredUsername' | 'getHost'> &
     PickWith<ActorModel, 'Account', AccountInclude> &
-    PickWith<ActorModel, 'Avatar', Pick<AvatarModel, 'filename' | 'getStaticPath'>> &
-    PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>
+    PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>> &
+    PickWithOpt<ActorModel, 'Avatar', Pick<AvatarModel, 'filename' | 'getStaticPath'>>
 
-  export type ActorFollowing = Pick<ActorModel, 'preferredUsername'> &
+  export type ActorFollowing = Pick<ActorModel, 'preferredUsername' | 'type' | 'getHost'> &
     PickWith<ActorModel, 'VideoChannel', VideoChannelInclude> &
-    PickWith<ActorModel, 'Account', AccountInclude>
+    PickWith<ActorModel, 'Account', AccountInclude> &
+    PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>
 
   export type ActorFollowInclude = Pick<ActorFollowModel, 'id' | 'state'> &
     PickWith<ActorFollowModel, 'ActorFollower', ActorFollower> &
index 1dedfa37f79e787d5f9fe89b41942576aa10b202..e128804546cd5d03edc06cdd8205d3ae82189762 100644 (file)
@@ -13,6 +13,9 @@ export type MVideoBlacklistUnfederated = Pick<MVideoBlacklist, 'unfederated'>
 
 // ############################################################################
 
+export type MVideoBlacklistLightVideo = MVideoBlacklistLight &
+  Use<'Video', MVideo>
+
 export type MVideoBlacklistVideo = MVideoBlacklist &
   Use<'Video', MVideo>
 
index 4b5cf4d7e14d37b88fcb1706bc8845da3419ad75..1abb4f73e3a4747ee6930b648f21b005a5d8d9da 100644 (file)
@@ -11,3 +11,12 @@ export type PickWith<T, KT extends keyof T, V> = {
 export type PickWithOpt<T, KT extends keyof T, V> = {
   [P in KT]?: T[P] extends V ? V : never
 }
+
+// https://github.com/krzkaczor/ts-essentials Rocks!
+export type DeepPartial<T> = {
+  [P in keyof T]?: T[P] extends Array<infer U>
+    ? Array<DeepPartial<U>>
+    : T[P] extends ReadonlyArray<infer U>
+      ? ReadonlyArray<DeepPartial<U>>
+      : DeepPartial<T[P]>
+};
index 8736f083f55ea8e6c6245a1ac3abf2c77fd6fed5..d784af9a93f3f14954890765abd8c4ba1d1dcc0c 100644 (file)
@@ -1,5 +1,7 @@
 import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
 import { CustomConfig } from '../../models/server/custom-config.model'
+import { DeepPartial } from '@server/typings/utils'
+import { merge } from 'lodash'
 
 function getConfig (url: string) {
   const path = '/api/v1/config'
@@ -44,7 +46,7 @@ function updateCustomConfig (url: string, token: string, newCustomConfig: Custom
   })
 }
 
-function updateCustomSubConfig (url: string, token: string, newConfig: any) {
+function updateCustomSubConfig (url: string, token: string, newConfig: DeepPartial<CustomConfig>) {
   const updateParams: CustomConfig = {
     instance: {
       name: 'PeerTube updated',
@@ -130,10 +132,21 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
         enabled: true,
         manualApproval: false
       }
+    },
+    followings: {
+      instance: {
+        autoFollowBack: {
+          enabled: false
+        },
+        autoFollowIndex: {
+          indexUrl: 'https://instances.joinpeertube.org',
+          enabled: false
+        }
+      }
     }
   }
 
-  Object.assign(updateParams, newConfig)
+  merge(updateParams, newConfig)
 
   return updateCustomConfig(url, token, updateParams)
 }
index f7de542bfe731b87c9ce94bc493cab7ea2e0a161..9a5fd7e86824e3e27386ecc6a237be97cb70397b 100644 (file)
@@ -279,8 +279,9 @@ async function checkNewActorFollow (
       expect(notification.actorFollow.follower.name).to.equal(followerName)
       expect(notification.actorFollow.follower.host).to.not.be.undefined
 
-      expect(notification.actorFollow.following.displayName).to.equal(followingDisplayName)
-      expect(notification.actorFollow.following.type).to.equal(followType)
+      const following = notification.actorFollow.following
+      expect(following.displayName).to.equal(followingDisplayName)
+      expect(following.type).to.equal(followType)
     } else {
       expect(notification).to.satisfy(n => {
         return n.type !== notificationType ||
@@ -327,6 +328,37 @@ async function checkNewInstanceFollower (base: CheckerBaseParams, followerHost:
   await checkNotification(base, notificationChecker, emailFinder, type)
 }
 
+async function checkAutoInstanceFollowing (base: CheckerBaseParams, followerHost: string, followingHost: string, type: CheckerType) {
+  const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      const following = notification.actorFollow.following
+      checkActor(following)
+      expect(following.name).to.equal('peertube')
+      expect(following.host).to.equal(followingHost)
+
+      expect(notification.actorFollow.follower.name).to.equal('peertube')
+      expect(notification.actorFollow.follower.host).to.equal(followerHost)
+    } else {
+      expect(notification).to.satisfy(n => {
+        return n.type !== notificationType || n.actorFollow.following.host !== followingHost
+      })
+    }
+  }
+
+  function emailFinder (email: object) {
+    const text: string = email[ 'text' ]
+
+    return text.includes(' automatically followed a new instance') && text.includes(followingHost)
+  }
+
+  await checkNotification(base, notificationChecker, emailFinder, type)
+}
+
 async function checkCommentMention (
   base: CheckerBaseParams,
   uuid: string,
@@ -427,8 +459,8 @@ async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, vi
       expect(notification).to.not.be.undefined
       expect(notification.type).to.equal(notificationType)
 
-      expect(notification.video.id).to.be.a('number')
-      checkVideo(notification.video, videoName, videoUUID)
+      expect(notification.videoBlacklist.video.id).to.be.a('number')
+      checkVideo(notification.videoBlacklist.video, videoName, videoUUID)
     } else {
       expect(notification).to.satisfy((n: UserNotification) => {
         return n === undefined || n.video === undefined || n.video.uuid !== videoUUID
@@ -480,6 +512,7 @@ export {
   markAsReadAllNotifications,
   checkMyVideoImportIsFinished,
   checkUserRegistered,
+  checkAutoInstanceFollowing,
   checkVideoIsPublished,
   checkNewVideoFromSubscription,
   checkNewActorFollow,
index a0541f5b621f17fbf8880003bb9c308907f933b3..1073ba32c60f45b5ae2c83e58ee3b32221ab8f13 100644 (file)
@@ -99,4 +99,16 @@ export interface CustomConfig {
     }
   }
 
+  followings: {
+    instance: {
+      autoFollowBack: {
+        enabled: boolean
+      }
+
+      autoFollowIndex: {
+        enabled: boolean
+        indexUrl: string
+      }
+    }
+  }
 }
index e2a882b6916e324f9c28289dfc6c3b2ea3b33081..451f40d5841754cdaeeb00918be2e86898937764 100644 (file)
@@ -16,4 +16,5 @@ export interface UserNotificationSetting {
   newFollow: UserNotificationSettingValue
   commentMention: UserNotificationSettingValue
   newInstanceFollower: UserNotificationSettingValue
+  autoInstanceFollowing: UserNotificationSettingValue
 }
index fafc2b7d74e12cca8ae6618627166d51f877bc3b..e9be1ca7fd76c6a7f844f6304dcb6f45c63ad7f8 100644 (file)
@@ -19,7 +19,9 @@ export enum UserNotificationType {
 
   VIDEO_AUTO_BLACKLIST_FOR_MODERATORS = 12,
 
-  NEW_INSTANCE_FOLLOWER = 13
+  NEW_INSTANCE_FOLLOWER = 13,
+
+  AUTO_INSTANCE_FOLLOWING = 14
 }
 
 export interface VideoInfo {
@@ -78,10 +80,12 @@ export interface UserNotification {
     id: number
     follower: ActorInfo
     state: FollowState
+
     following: {
-      type: 'account' | 'channel'
+      type: 'account' | 'channel' | 'instance'
       name: string
       displayName: string
+      host: string
     }
   }
 
index 5ed870c5c3a74b7bdff326e54c7d53a76cdc604a..7e05994fbae954e2dd2053e46d1de3351322d4e9 100644 (file)
@@ -17,8 +17,7 @@
     "typeRoots": [ "node_modules/@types", "server/typings" ],
     "baseUrl": "./",
     "paths": {
-      "@server/typings/*": [ "server/typings/*" ],
-      "@server/models/*": [ "server/models/*" ]
+      "@server/*": [ "server/*" ]
     }
   },
   "exclude": [
index b5a3a3f477303d73ecf71ca04bc02790544dc6c6..bab3aa1626c2d4a0e3e0057a710f0bce86aaf97a 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -4610,6 +4610,11 @@ mocha@^6.0.0:
     yargs-parser "13.0.0"
     yargs-unparser "1.5.0"
 
+module-alias@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.1.tgz#553aea9dc7f99cd45fd75e34a574960dc46550da"
+  integrity sha512-LTez0Eo+YtfUhgzhu/LqxkUzOpD+k5C0wXBLun0L1qE2BhHf6l09dqam8e7BnoMYA6mAlP0vSsGFQ8QHhGN/aQ==
+
 moment-timezone@^0.5.21, moment-timezone@^0.5.25:
   version "0.5.26"
   resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.26.tgz#c0267ca09ae84631aa3dc33f65bedbe6e8e0d772"