Browse Source

Add auto follow back support for instances

Chocobozzz 4 years ago
parent
commit
8424c4026a
44 changed files with 651 additions and 156 deletions
  1. 1 1
      client/src/app/shared/users/user-notification.model.ts
  2. 16 0
      config/default.yaml
  3. 15 0
      config/production.yaml.example
  4. 5 1
      package.json
  5. 2 0
      server.ts
  6. 12 0
      server/controllers/api/config.ts
  7. 3 0
      server/controllers/api/server/follows.ts
  8. 2 1
      server/controllers/api/users/my-notifications.ts
  9. 17 0
      server/initializers/config.ts
  10. 36 0
      server/lib/activitypub/follow.ts
  11. 1 1
      server/lib/activitypub/process/process-accept.ts
  12. 12 8
      server/lib/activitypub/process/process-follow.ts
  13. 0 1
      server/lib/activitypub/send/send-follow.ts
  14. 28 5
      server/lib/emailer.ts
  15. 13 11
      server/lib/job-queue/handlers/activitypub-follow.ts
  16. 4 1
      server/lib/job-queue/handlers/video-import.ts
  17. 109 75
      server/lib/notifier.ts
  18. 2 1
      server/lib/user.ts
  19. 5 3
      server/lib/video-blacklist.ts
  20. 2 0
      server/middlewares/validators/user-notifications.ts
  21. 1 1
      server/models/account/account.ts
  22. 11 1
      server/models/account/user-notification-setting.ts
  23. 15 4
      server/models/account/user-notification.ts
  24. 2 10
      server/models/activitypub/actor.ts
  25. 10 0
      server/models/server/server.ts
  26. 1 1
      server/models/video/video-channel.ts
  27. 21 2
      server/tests/api/check-params/config.ts
  28. 2 1
      server/tests/api/check-params/user-notifications.ts
  29. 36 4
      server/tests/api/notifications/user-notifications.ts
  30. 148 0
      server/tests/api/server/auto-follows.ts
  31. 19 0
      server/tests/api/server/config.ts
  32. 1 0
      server/tests/api/server/index.ts
  33. 3 7
      server/typings/models/account/actor-follow.ts
  34. 1 1
      server/typings/models/account/actor.ts
  35. 6 5
      server/typings/models/user/user-notification.ts
  36. 3 0
      server/typings/models/video/video-blacklist.ts
  37. 9 0
      server/typings/utils.ts
  38. 15 2
      shared/extra-utils/server/config.ts
  39. 37 4
      shared/extra-utils/users/user-notifications.ts
  40. 12 0
      shared/models/server/custom-config.model.ts
  41. 1 0
      shared/models/users/user-notification-setting.model.ts
  42. 6 2
      shared/models/users/user-notification.model.ts
  43. 1 2
      tsconfig.json
  44. 5 0
      yarn.lock

+ 1 - 1
client/src/app/shared/users/user-notification.model.ts

@@ -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:

+ 16 - 0
config/default.yaml

@@ -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'

+ 15 - 0
config/production.yaml.example

@@ -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'

+ 5 - 1
package.json

@@ -132,6 +132,7 @@
     "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",
@@ -224,5 +225,8 @@
   "scripty": {
     "silent": true
   },
-  "sasslintConfig": "client/.sass-lint.yml"
+  "sasslintConfig": "client/.sass-lint.yml",
+  "_moduleAliases": {
+    "@server": "dist/server"
+  }
 }

+ 2 - 0
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'
 

+ 12 - 0
server/controllers/api/config.ts

@@ -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
+        }
+      }
     }
   }
 }

+ 3 - 0
server/controllers/api/server/follows.ts

@@ -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()
 }

+ 2 - 1
server/controllers/api/users/my-notifications.ts

@@ -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)

+ 17 - 0
server/initializers/config.ts

@@ -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') }
   }

+ 36 - 0
server/lib/activitypub/follow.ts

@@ -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
+}

+ 1 - 1
server/lib/activitypub/process/process-accept.ts

@@ -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)

+ 12 - 8
server/lib/activitypub/process/process-follow.ts

@@ -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)
     }
   }

+ 0 - 1
server/lib/activitypub/send/send-follow.ts

@@ -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'

+ 28 - 5
server/lib/emailer.ts

@@ -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.` +

+ 13 - 11
server/lib/job-queue/handlers/activitypub-follow.ts

@@ -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
 }

+ 4 - 1
server/lib/job-queue/handlers/video-import.ts

@@ -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)
     }

+ 109 - 75
server/lib/notifier.ts

@@ -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[] = []
 

+ 2 - 1
server/lib/user.ts

@@ -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 })

+ 5 - 3
server/lib/video-blacklist.ts

@@ -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)
 

+ 2 - 0
server/middlewares/validators/user-notifications.ts

@@ -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 })

+ 1 - 1
server/models/account/account.ts

@@ -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

+ 11 - 1
server/models/account/user-notification-setting.ts

@@ -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
     }
   }
 }

+ 15 - 4
server/models/account/user-notification.ts

@@ -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
 

+ 2 - 10
server/models/activitypub/actor.ts

@@ -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(),

+ 10 - 0
server/models/server/server.ts

@@ -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: {

+ 1 - 1
server/models/video/video-channel.ts

@@ -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,

+ 21 - 2
server/tests/api/check-params/config.ts

@@ -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'
+        }
+      }
     }
   }
 

+ 2 - 1
server/tests/api/check-params/user-notifications.ts

@@ -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 () {

+ 36 - 4
server/tests/api/notifications/user-notifications.ts

@@ -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 () {

+ 148 - 0
server/tests/api/server/auto-follows.ts

@@ -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)
+  })
+})

+ 19 - 0
server/tests/api/server/config.ts

@@ -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)

+ 1 - 0
server/tests/api/server/index.ts

@@ -1,3 +1,4 @@
+import './auto-follows'
 import './config'
 import './contact-form'
 import './email'

+ 3 - 7
server/typings/models/account/actor-follow.ts

@@ -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>
 

+ 1 - 1
server/typings/models/account/actor.ts

@@ -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>

+ 6 - 5
server/typings/models/user/user-notification.ts

@@ -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> &

+ 3 - 0
server/typings/models/video/video-blacklist.ts

@@ -13,6 +13,9 @@ export type MVideoBlacklistUnfederated = Pick<MVideoBlacklist, 'unfederated'>
 
 // ############################################################################
 
+export type MVideoBlacklistLightVideo = MVideoBlacklistLight &
+  Use<'Video', MVideo>
+
 export type MVideoBlacklistVideo = MVideoBlacklist &
   Use<'Video', MVideo>
 

+ 9 - 0
server/typings/utils.ts

@@ -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]>
+};

+ 15 - 2
shared/extra-utils/server/config.ts

@@ -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)
 }

+ 37 - 4
shared/extra-utils/users/user-notifications.ts

@@ -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,

+ 12 - 0
shared/models/server/custom-config.model.ts

@@ -99,4 +99,16 @@ export interface CustomConfig {
     }
   }
 
+  followings: {
+    instance: {
+      autoFollowBack: {
+        enabled: boolean
+      }
+
+      autoFollowIndex: {
+        enabled: boolean
+        indexUrl: string
+      }
+    }
+  }
 }

+ 1 - 0
shared/models/users/user-notification-setting.model.ts

@@ -16,4 +16,5 @@ export interface UserNotificationSetting {
   newFollow: UserNotificationSettingValue
   commentMention: UserNotificationSettingValue
   newInstanceFollower: UserNotificationSettingValue
+  autoInstanceFollowing: UserNotificationSettingValue
 }

+ 6 - 2
shared/models/users/user-notification.model.ts

@@ -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
     }
   }
 

+ 1 - 2
tsconfig.json

@@ -17,8 +17,7 @@
     "typeRoots": [ "node_modules/@types", "server/typings" ],
     "baseUrl": "./",
     "paths": {
-      "@server/typings/*": [ "server/typings/*" ],
-      "@server/models/*": [ "server/models/*" ]
+      "@server/*": [ "server/*" ]
     }
   },
   "exclude": [

+ 5 - 0
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"