Add ability to manually approves instance followers in REST API
authorChocobozzz <me@florianbigard.com>
Mon, 8 Apr 2019 13:18:04 +0000 (15:18 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 8 Apr 2019 13:18:04 +0000 (15:18 +0200)
16 files changed:
config/default.yaml
config/production.yaml.example
server/controllers/api/config.ts
server/controllers/api/server/follows.ts
server/initializers/checker-before-init.ts
server/initializers/constants.ts
server/lib/activitypub/process/process-follow.ts
server/middlewares/validators/config.ts
server/middlewares/validators/follows.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/follows.ts
server/tests/api/server/config.ts
server/tests/api/server/follows-moderation.ts
shared/models/server/custom-config.model.ts
shared/utils/server/config.ts
shared/utils/server/follows.ts

index 51f3ad833ceb7dfbdb82156440cc5a2473b96280..0d6e34d86b7d98d966a9f98a822dfe65670eecee 100644 (file)
@@ -205,3 +205,5 @@ followers:
   instance:
     # Allow or not other instances to follow yours
     enabled: true
+    # Whether or not an administrator must manually validate a new follower
+    manual_approval: false
index a2811abd621d69fcbc51a494e66eba0a352e2d70..5029cc25b744e47af97dcaf9c17327e3a7e65695 100644 (file)
@@ -222,3 +222,5 @@ followers:
   instance:
     # Allow or not other instances to follow yours
     enabled: true
+    # Whether or not an administrator must manually validate a new follower
+    manual_approval: false
index f9bb0b94770c99dcb51646190cc3318034c0895c..5c4f455ee5160f9cfa210e8d8d9a34865c31a68b 100644 (file)
@@ -282,7 +282,8 @@ function customConfig (): CustomConfig {
     },
     followers: {
       instance: {
-        enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED
+        enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
+        manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
       }
     }
   }
index 87cf091cbffc0e64c5816c75a4c7de9e4ec6a66a..207a09a4cdfda1d5bb39ee42109af9ca3dc38932 100644 (file)
@@ -3,7 +3,7 @@ import { UserRight } from '../../../../shared/models/users'
 import { logger } from '../../../helpers/logger'
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
 import { sequelizeTypescript, SERVER_ACTOR_NAME } from '../../../initializers'
-import { sendReject, sendUndoFollow } from '../../../lib/activitypub/send'
+import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send'
 import {
   asyncMiddleware,
   authenticate,
@@ -14,10 +14,11 @@ import {
   setDefaultSort
 } from '../../../middlewares'
 import {
+  acceptOrRejectFollowerValidator,
   followersSortValidator,
   followingSortValidator,
   followValidator,
-  removeFollowerValidator,
+  getFollowerValidator,
   removeFollowingValidator
 } from '../../../middlewares/validators'
 import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
@@ -59,8 +60,24 @@ serverFollowsRouter.get('/followers',
 serverFollowsRouter.delete('/followers/:nameWithHost',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
-  asyncMiddleware(removeFollowerValidator),
-  asyncMiddleware(removeFollower)
+  asyncMiddleware(getFollowerValidator),
+  asyncMiddleware(removeOrRejectFollower)
+)
+
+serverFollowsRouter.post('/followers/:nameWithHost/reject',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
+  asyncMiddleware(getFollowerValidator),
+  acceptOrRejectFollowerValidator,
+  asyncMiddleware(removeOrRejectFollower)
+)
+
+serverFollowsRouter.post('/followers/:nameWithHost/accept',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
+  asyncMiddleware(getFollowerValidator),
+  acceptOrRejectFollowerValidator,
+  asyncMiddleware(acceptFollower)
 )
 
 // ---------------------------------------------------------------------------
@@ -136,7 +153,7 @@ async function removeFollowing (req: express.Request, res: express.Response) {
   return res.status(204).end()
 }
 
-async function removeFollower (req: express.Request, res: express.Response) {
+async function removeOrRejectFollower (req: express.Request, res: express.Response) {
   const follow = res.locals.follow
 
   await sendReject(follow.ActorFollower, follow.ActorFollowing)
@@ -145,3 +162,14 @@ async function removeFollower (req: express.Request, res: express.Response) {
 
   return res.status(204).end()
 }
+
+async function acceptFollower (req: express.Request, res: express.Response) {
+  const follow = res.locals.follow
+
+  await sendAccept(follow)
+
+  follow.state = 'accepted'
+  await follow.save()
+
+  return res.status(204).end()
+}
index a9896907d5d169ad39a39ec7dc3d3d071cd28ac7..3095913a3209f79b49a9b4a85a48753cf3bee50e 100644 (file)
@@ -25,7 +25,7 @@ function checkMissedConfig () {
     'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
     'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
     'services.twitter.username', 'services.twitter.whitelisted',
-    'followers.instance.enabled'
+    'followers.instance.enabled', 'followers.instance.manual_approval'
   ]
   const requiredAlternatives = [
     [ // set
index 43c5ec54cde1f0f739b9fd8b4582f2d9eef6d5d9..7d9ffc6682186fb41638d71d3dd7e11f1cb51796 100644 (file)
@@ -327,7 +327,8 @@ const CONFIG = {
   },
   FOLLOWERS: {
     INSTANCE: {
-      get ENABLED () { return config.get<boolean>('followers.instance.enabled') }
+      get ENABLED () { return config.get<boolean>('followers.instance.enabled') },
+      get MANUAL_APPROVAL () { return config.get<boolean>('followers.instance.manual_approval') }
     }
   }
 }
index cecf09b47bf2d169fcdf9e5ccedc1a824a048cea..140bbe9f1322e26fe4cf5326e2e1594810051d75 100644 (file)
@@ -32,6 +32,8 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
 
     const serverActor = await getServerActor()
     if (targetActor.id === serverActor.id && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
+      logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
+
       return sendReject(actor, targetActor)
     }
 
@@ -43,7 +45,7 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
       defaults: {
         actorId: actor.id,
         targetActorId: targetActor.id,
-        state: 'accepted'
+        state: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL ? 'pending' : 'accepted'
       },
       transaction: t
     })
@@ -51,7 +53,7 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
     actorFollow.ActorFollower = actor
     actorFollow.ActorFollowing = targetActor
 
-    if (actorFollow.state !== 'accepted') {
+    if (actorFollow.state !== 'accepted' && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === false) {
       actorFollow.state = 'accepted'
       await actorFollow.save({ transaction: t })
     }
@@ -60,7 +62,7 @@ async function processFollow (actor: ActorModel, targetActorURL: string) {
     actorFollow.ActorFollowing = targetActor
 
     // Target sends to actor he accepted the follow request
-    await sendAccept(actorFollow)
+    if (actorFollow.state === 'accepted') await sendAccept(actorFollow)
 
     return { actorFollow, created }
   })
index 270ce66f68a75a0058f2f2cc3c8a804b456ad91f..d015fa6fece80d738ee553f477ed50c27c6ba050 100644 (file)
@@ -44,6 +44,9 @@ const customConfigUpdateValidator = [
   body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
   body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
 
+  body('followers.instance.enabled').isBoolean().withMessage('Should have a valid followers of instance boolean'),
+  body('followers.instance.manualApproval').isBoolean().withMessage('Should have a valid manual approval boolean'),
+
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
 
index 38df39fdaae04d5b44892a8be301894f1125d1ff..b360cf95ee0656a8227e613a32b6ccf0b0db490f 100644 (file)
@@ -57,11 +57,11 @@ const removeFollowingValidator = [
   }
 ]
 
-const removeFollowerValidator = [
+const getFollowerValidator = [
   param('nameWithHost').custom(isValidActorHandle).withMessage('Should have a valid nameWithHost'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking remove follower parameters', { parameters: req.params })
+    logger.debug('Checking get follower parameters', { parameters: req.params })
 
     if (areValidationErrors(req, res)) return
 
@@ -90,10 +90,24 @@ const removeFollowerValidator = [
   }
 ]
 
+const acceptOrRejectFollowerValidator = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking accept/reject follower parameters', { parameters: req.params })
+
+    const follow = res.locals.follow
+    if (follow.state !== 'pending') {
+      return res.status(400).json({ error: 'Follow is not in pending state.' }).end()
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
   followValidator,
   removeFollowingValidator,
-  removeFollowerValidator
+  getFollowerValidator,
+  acceptOrRejectFollowerValidator
 }
index d117f26e61b3faeeb924a68bd2d9076e25aa42dd..01ab845846999e5c737d007bf9f62451f91ecf0e 100644 (file)
@@ -90,7 +90,8 @@ describe('Test config API validators', function () {
     },
     followers: {
       instance: {
-        enabled: false
+        enabled: false,
+        manualApproval: true
       }
     }
   }
index 67fa43778b406eed0cbd8bcbac2d9ce4fb33b184..ed1d2db595eb8124f05eea2c2500104afbaa2353 100644 (file)
@@ -184,6 +184,86 @@ describe('Test server follows API validators', function () {
       })
     })
 
+    describe('When accepting a follower', function () {
+      const path = '/api/v1/server/followers'
+
+      it('Should fail with an invalid token', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9002/accept',
+          token: 'fake_token',
+          statusCodeExpected: 401
+        })
+      })
+
+      it('Should fail if the user is not an administrator', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9002/accept',
+          token: userAccessToken,
+          statusCodeExpected: 403
+        })
+      })
+
+      it('Should fail with an invalid follower', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto/accept',
+          token: server.accessToken,
+          statusCodeExpected: 400
+        })
+      })
+
+      it('Should fail with an unknown follower', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9003/accept',
+          token: server.accessToken,
+          statusCodeExpected: 404
+        })
+      })
+    })
+
+    describe('When rejecting a follower', function () {
+      const path = '/api/v1/server/followers'
+
+      it('Should fail with an invalid token', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9002/reject',
+          token: 'fake_token',
+          statusCodeExpected: 401
+        })
+      })
+
+      it('Should fail if the user is not an administrator', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9002/reject',
+          token: userAccessToken,
+          statusCodeExpected: 403
+        })
+      })
+
+      it('Should fail with an invalid follower', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto/reject',
+          token: server.accessToken,
+          statusCodeExpected: 400
+        })
+      })
+
+      it('Should fail with an unknown follower', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path: path + '/toto@localhost:9003/reject',
+          token: server.accessToken,
+          statusCodeExpected: 404
+        })
+      })
+    })
+
     describe('When removing following', function () {
       const path = '/api/v1/server/following'
 
index cb2700f2949a862980a030c001c162ad499062ec..5373d02f228c62c29b60b97a3a8f5438c44de020 100644 (file)
@@ -65,6 +65,7 @@ function checkInitialConfig (data: CustomConfig) {
   expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false
 
   expect(data.followers.instance.enabled).to.be.true
+  expect(data.followers.instance.manualApproval).to.be.false
 }
 
 function checkUpdatedConfig (data: CustomConfig) {
@@ -109,6 +110,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true
 
   expect(data.followers.instance.enabled).to.be.false
+  expect(data.followers.instance.manualApproval).to.be.true
 }
 
 describe('Test config', function () {
@@ -241,7 +243,8 @@ describe('Test config', function () {
       },
       followers: {
         instance: {
-          enabled: false
+          enabled: false,
+          manualApproval: true
         }
       }
     }
index a360706f205df4f9bf28772bf5a8a97fd7acfe94..0bb3aa866d285855097ba5232c1bcc9232ffbc73 100644 (file)
@@ -3,6 +3,7 @@
 import * as chai from 'chai'
 import 'mocha'
 import {
+  acceptFollower,
   flushAndRunMultipleServers,
   killallServers,
   ServerInfo,
@@ -13,19 +14,21 @@ import {
   follow,
   getFollowersListPaginationAndSort,
   getFollowingListPaginationAndSort,
-  removeFollower
+  removeFollower,
+  rejectFollower
 } from '../../../../shared/utils/server/follows'
 import { waitJobs } from '../../../../shared/utils/server/jobs'
 import { ActorFollow } from '../../../../shared/models/actors'
 
 const expect = chai.expect
 
-async function checkHasFollowers (servers: ServerInfo[]) {
+async function checkServer1And2HasFollowers (servers: ServerInfo[], state = 'accepted') {
   {
     const res = await getFollowingListPaginationAndSort(servers[0].url, 0, 5, 'createdAt')
     expect(res.body.total).to.equal(1)
 
     const follow = res.body.data[0] as ActorFollow
+    expect(follow.state).to.equal(state)
     expect(follow.follower.url).to.equal('http://localhost:9001/accounts/peertube')
     expect(follow.following.url).to.equal('http://localhost:9002/accounts/peertube')
   }
@@ -35,6 +38,7 @@ async function checkHasFollowers (servers: ServerInfo[]) {
     expect(res.body.total).to.equal(1)
 
     const follow = res.body.data[0] as ActorFollow
+    expect(follow.state).to.equal(state)
     expect(follow.follower.url).to.equal('http://localhost:9001/accounts/peertube')
     expect(follow.following.url).to.equal('http://localhost:9002/accounts/peertube')
   }
@@ -58,7 +62,7 @@ describe('Test follows moderation', function () {
   before(async function () {
     this.timeout(30000)
 
-    servers = await flushAndRunMultipleServers(2)
+    servers = await flushAndRunMultipleServers(3)
 
     // Get the access tokens
     await setAccessTokensToServers(servers)
@@ -73,7 +77,7 @@ describe('Test follows moderation', function () {
   })
 
   it('Should have correct follows', async function () {
-    await checkHasFollowers(servers)
+    await checkServer1And2HasFollowers(servers)
   })
 
   it('Should remove follower on server 2', async function () {
@@ -90,7 +94,8 @@ describe('Test follows moderation', function () {
     const subConfig = {
       followers: {
         instance: {
-          enabled: false
+          enabled: false,
+          manualApproval: false
         }
       }
     }
@@ -107,7 +112,8 @@ describe('Test follows moderation', function () {
     const subConfig = {
       followers: {
         instance: {
-          enabled: true
+          enabled: true,
+          manualApproval: false
         }
       }
     }
@@ -117,7 +123,70 @@ describe('Test follows moderation', function () {
     await follow(servers[0].url, [ servers[1].url ], servers[0].accessToken)
     await waitJobs(servers)
 
-    await checkHasFollowers(servers)
+    await checkServer1And2HasFollowers(servers)
+  })
+
+  it('Should manually approve followers', async function () {
+    this.timeout(20000)
+
+    await removeFollower(servers[1].url, servers[1].accessToken, servers[0])
+    await waitJobs(servers)
+
+    const subConfig = {
+      followers: {
+        instance: {
+          enabled: true,
+          manualApproval: true
+        }
+      }
+    }
+
+    await updateCustomSubConfig(servers[1].url, servers[1].accessToken, subConfig)
+    await updateCustomSubConfig(servers[2].url, servers[2].accessToken, subConfig)
+
+    await follow(servers[0].url, [ servers[1].url ], servers[0].accessToken)
+    await waitJobs(servers)
+
+    await checkServer1And2HasFollowers(servers, 'pending')
+  })
+
+  it('Should accept a follower', async function () {
+    await acceptFollower(servers[1].url, servers[1].accessToken, 'peertube@localhost:9001')
+    await waitJobs(servers)
+
+    await checkServer1And2HasFollowers(servers)
+  })
+
+  it('Should reject another follower', async function () {
+    this.timeout(20000)
+
+    await follow(servers[0].url, [ servers[2].url ], servers[0].accessToken)
+    await waitJobs(servers)
+
+    {
+      const res = await getFollowingListPaginationAndSort(servers[0].url, 0, 5, 'createdAt')
+      expect(res.body.total).to.equal(2)
+    }
+
+    {
+      const res = await getFollowersListPaginationAndSort(servers[1].url, 0, 5, 'createdAt')
+      expect(res.body.total).to.equal(1)
+    }
+
+    {
+      const res = await getFollowersListPaginationAndSort(servers[2].url, 0, 5, 'createdAt')
+      expect(res.body.total).to.equal(1)
+    }
+
+    await rejectFollower(servers[2].url, servers[2].accessToken, 'peertube@localhost:9001')
+    await waitJobs(servers)
+
+    await checkServer1And2HasFollowers(servers)
+
+    {
+      const res = await getFollowersListPaginationAndSort(servers[ 2 ].url, 0, 5, 'createdAt')
+      expect(res.body.total).to.equal(0)
+    }
   })
 
   after(async function () {
index 642ffea39e6e0b25beba7b21b3af6e9e9934b18f..ca52eff4ba761b48cd43510ab89424004b3ae99d 100644 (file)
@@ -88,7 +88,8 @@ export interface CustomConfig {
 
   followers: {
     instance: {
-      enabled: boolean
+      enabled: boolean,
+      manualApproval: boolean
     }
   }
 
index 21c6897143b2d2dcc2db1f014354e58a6455a0f2..deb77e9c0e1067f98a9a73a2b94711022dd3a056 100644 (file)
@@ -122,7 +122,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
     },
     followers: {
       instance: {
-        enabled: true
+        enabled: true,
+        manualApproval: false
       }
     }
   }
index 949fd8109c7bcd7b6524a2dee74a2a30ebea2405..1505804de45537fe8225f11aa06dec3589487942 100644 (file)
@@ -1,6 +1,7 @@
 import * as request from 'supertest'
 import { ServerInfo } from './servers'
 import { waitJobs } from './jobs'
+import { makeGetRequest, makePostBodyRequest } from '..'
 
 function getFollowersListPaginationAndSort (url: string, start: number, count: number, sort: string, search?: string) {
   const path = '/api/v1/server/followers'
@@ -16,6 +17,28 @@ function getFollowersListPaginationAndSort (url: string, start: number, count: n
     .expect('Content-Type', /json/)
 }
 
+function acceptFollower (url: string, token: string, follower: string, statusCodeExpected = 204) {
+  const path = '/api/v1/server/followers/' + follower + '/accept'
+
+  return makePostBodyRequest({
+    url,
+    token,
+    path,
+    statusCodeExpected
+  })
+}
+
+function rejectFollower (url: string, token: string, follower: string, statusCodeExpected = 204) {
+  const path = '/api/v1/server/followers/' + follower + '/reject'
+
+  return makePostBodyRequest({
+    url,
+    token,
+    path,
+    statusCodeExpected
+  })
+}
+
 function getFollowingListPaginationAndSort (url: string, start: number, count: number, sort: string, search?: string) {
   const path = '/api/v1/server/following'
 
@@ -30,18 +53,16 @@ function getFollowingListPaginationAndSort (url: string, start: number, count: n
     .expect('Content-Type', /json/)
 }
 
-async function follow (follower: string, following: string[], accessToken: string, expectedStatus = 204) {
+function follow (follower: string, following: string[], accessToken: string, expectedStatus = 204) {
   const path = '/api/v1/server/following'
 
   const followingHosts = following.map(f => f.replace(/^http:\/\//, ''))
-  const res = await request(follower)
+  return request(follower)
     .post(path)
     .set('Accept', 'application/json')
     .set('Authorization', 'Bearer ' + accessToken)
     .send({ 'hosts': followingHosts })
     .expect(expectedStatus)
-
-  return res
 }
 
 async function unfollow (url: string, accessToken: string, target: ServerInfo, expectedStatus = 204) {
@@ -84,5 +105,7 @@ export {
   unfollow,
   removeFollower,
   follow,
-  doubleFollow
+  doubleFollow,
+  acceptFollower,
+  rejectFollower
 }