]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability for instances to follow any actor
authorChocobozzz <me@florianbigard.com>
Tue, 20 Jul 2021 12:15:15 +0000 (14:15 +0200)
committerChocobozzz <me@florianbigard.com>
Wed, 21 Jul 2021 11:35:31 +0000 (13:35 +0200)
45 files changed:
client/src/app/+admin/admin.component.ts
client/src/app/+admin/admin.module.ts
client/src/app/+admin/follows/followers-list/followers-list.component.html
client/src/app/+admin/follows/following-list/follow-modal.component.html [new file with mode: 0644]
client/src/app/+admin/follows/following-list/follow-modal.component.scss [new file with mode: 0644]
client/src/app/+admin/follows/following-list/follow-modal.component.ts [new file with mode: 0644]
client/src/app/+admin/follows/following-list/following-list.component.html
client/src/app/+admin/follows/following-list/following-list.component.ts
client/src/app/+admin/follows/following-list/index.ts
client/src/app/+admin/follows/follows.routes.ts
client/src/app/shared/form-validators/batch-domains-validators.ts [deleted file]
client/src/app/shared/form-validators/host-validators.ts [new file with mode: 0644]
client/src/app/shared/form-validators/host.ts [deleted file]
client/src/app/shared/form-validators/index.ts
client/src/app/shared/shared-instance/instance-follow.service.ts
client/src/app/shared/shared-moderation/batch-domains-modal.component.html
client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
server/controllers/api/server/follows.ts
server/helpers/custom-validators/follows.ts
server/helpers/custom-validators/servers.ts
server/lib/activitypub/crawl.ts
server/lib/activitypub/follow.ts
server/middlewares/validators/follows.ts
server/models/actor/actor-follow.ts
server/models/video/sql/videos-id-list-query-builder.ts
server/tests/api/check-params/follows.ts
server/tests/api/moderation/blocklist.ts
server/tests/api/notifications/moderation-notifications.ts
server/tests/api/redundancy/redundancy-constraints.ts
server/tests/api/server/auto-follows.ts
server/tests/api/server/follows-moderation.ts
server/tests/api/server/follows.ts
server/tests/api/server/handle-down.ts
server/tests/api/server/stats.ts
server/tests/api/users/user-subscriptions.ts
server/tests/api/users/users.ts
shared/extra-utils/server/follows-command.ts
shared/extra-utils/server/follows.ts
shared/extra-utils/users/actors.ts [moved from shared/extra-utils/users/accounts.ts with 50% similarity]
shared/extra-utils/users/index.ts
shared/extra-utils/videos/comments-command.ts
shared/extra-utils/videos/videos-command.ts
shared/models/server/index.ts
shared/models/server/server-follow-create.model.ts [new file with mode: 0644]
support/doc/api/openapi.yaml

index dd92ed2caffaed20564d9d331ab116e800ac3d21..4b6fab6ed382e2b47350c61346febb5fcf00d77b 100644 (file)
@@ -26,12 +26,12 @@ export class AdminComponent implements OnInit {
       label: $localize`Federation`,
       children: [
         {
-          label: $localize`Instances you follow`,
+          label: $localize`Following`,
           routerLink: '/admin/follows/following-list',
           iconName: 'following'
         },
         {
-          label: $localize`Instances following you`,
+          label: $localize`Followers`,
           routerLink: '/admin/follows/followers-list',
           iconName: 'follower'
         },
index a7fe20b074cb12c015ab843ac10cbed465bce987..1ea7b9784ab08f4b2c9537dc2b71780a7e0ed14d 100644 (file)
@@ -25,7 +25,7 @@ import {
   EditVODTranscodingComponent
 } from './config'
 import { ConfigService } from './config/shared/config.service'
-import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
+import { FollowersListComponent, FollowModalComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
 import { FollowingListComponent } from './follows/following-list/following-list.component'
 import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
 import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
@@ -68,6 +68,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
     FollowsComponent,
     FollowersListComponent,
     FollowingListComponent,
+    FollowModalComponent,
     RedundancyCheckboxComponent,
     VideoRedundanciesListComponent,
     VideoRedundancyInformationComponent,
index c2e9a4df69fa61d6537697a2ccc17a8def72fd09..08459634d76a1701d7d6ccbd0e805e33a4cd1929 100644 (file)
@@ -1,6 +1,6 @@
 <h1>
   <my-global-icon iconName="follower" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>Instances following you</ng-container>
+  <ng-container i18n>Followers of your instance</ng-container>
 </h1>
 
 <p-table
@@ -21,7 +21,7 @@
   <ng-template pTemplate="header">
     <tr>
       <th style="width: 150px;" i18n>Actions</th>
-      <th i18n>Follower handle</th>
+      <th i18n>Follower</th>
       <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
       <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
       <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.html b/client/src/app/+admin/follows/following-list/follow-modal.component.html
new file mode 100644 (file)
index 0000000..d0761b7
--- /dev/null
@@ -0,0 +1,42 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 i18n class="modal-title">Follow</h4>
+
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="submit()">
+      <div class="form-group">
+        <label i18n for="hostsOrHandles">1 host (without "http://"), account handle or channel handle per line</label>
+
+        <textarea
+          [placeholder]="placeholder" formControlName="hostsOrHandles" type="text" id="hostsOrHandles" name="hostsOrHandles"
+          class="form-control" [ngClass]="{ 'input-error': formErrors['hostsOrHandles'] }" ngbAutofocus
+        ></textarea>
+
+        <div *ngIf="formErrors.hostsOrHandles" class="form-error">
+          {{ formErrors.hostsOrHandles }}
+
+          <div *ngIf="form.controls['hostsOrHandles'].errors.validHostsOrHandles">
+            {{ form.controls['hostsOrHandles'].errors.validHostsOrHandles.value }}
+          </div>
+        </div>
+      </div>
+
+      <div i18n *ngIf="httpEnabled() === false"  class="alert alert-warning">
+        It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
+      </div>
+
+      <div class="form-group inputs">
+        <input
+          type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
+          (click)="hide()" (key.enter)="hide()"
+        >
+
+        <input type="submit" i18n-value value="Follow" class="peertube-button orange-button" [disabled]="!form.valid" />
+      </div>
+    </form>
+  </div>
+
+</ng-template>
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.scss b/client/src/app/+admin/follows/following-list/follow-modal.component.scss
new file mode 100644 (file)
index 0000000..9621a56
--- /dev/null
@@ -0,0 +1,3 @@
+textarea {
+  height: 200px;
+}
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
new file mode 100644 (file)
index 0000000..dc69092
--- /dev/null
@@ -0,0 +1,69 @@
+import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier } from '@app/core'
+import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { InstanceFollowService } from '@app/shared/shared-instance'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+
+@Component({
+  selector: 'my-follow-modal',
+  templateUrl: './follow-modal.component.html',
+  styleUrls: [ './follow-modal.component.scss' ]
+})
+export class FollowModalComponent extends FormReactive implements OnInit {
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  @Output() newFollow = new EventEmitter<void>()
+
+  placeholder = 'example.com\nchocobozzz@example.com\nchocobozzz_channel@example.com'
+
+  private openedModal: NgbModalRef
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private modalService: NgbModal,
+    private followService: InstanceFollowService,
+    private notifier: Notifier
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      hostsOrHandles: UNIQUE_HOSTS_OR_HANDLE_VALIDATOR
+    })
+  }
+
+  openModal () {
+    this.openedModal = this.modalService.open(this.modal, { centered: true })
+  }
+
+  hide () {
+    this.openedModal.close()
+  }
+
+  submit () {
+    this.addFollowing()
+
+    this.form.reset()
+    this.hide()
+  }
+
+  httpEnabled () {
+    return window.location.protocol === 'https:'
+  }
+
+  private async addFollowing () {
+    const hostsOrHandles = splitAndGetNotEmpty(this.form.value['hostsOrHandles'])
+
+    this.followService.follow(hostsOrHandles).subscribe(
+      () => {
+        this.notifier.success($localize`Follow request(s) sent!`)
+        this.newFollow.emit()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+}
index e7c0c908823bc1ec4d6f9531a31ae7bf2e36d358..75b0efca85355f7de8f079275ebb4243f6bd4d5b 100644 (file)
@@ -1,6 +1,6 @@
 <h1>
   <my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>Instances you follow</ng-container>
+  <ng-container i18n>Your instance subscriptions</ng-container>
 </h1>
 
 <p-table
@@ -13,9 +13,9 @@
   <ng-template pTemplate="caption">
     <div class="caption">
       <div class="left-buttons">
-        <a class="follow-button" (click)="addDomainsToFollow()" (key.enter)="addDomainsToFollow()">
+        <a class="follow-button" (click)="openFollowModal()" (key.enter)="openFollowModal()">
           <my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
-          <ng-container i18n>Follow instances</ng-container>
+          <ng-container i18n>Follow</ng-container>
         </a>
       </div>
 
@@ -28,7 +28,7 @@
   <ng-template pTemplate="header">
     <tr>
       <th style="width: 150px;" i18n>Action</th>
-      <th i18n>Host</th>
+      <th i18n>Following</th>
       <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
       <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
       <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
@@ -41,8 +41,8 @@
         <my-delete-button label="Unfollow" i18n-label (click)="removeFollowing(follow)"></my-delete-button>
       </td>
       <td>
-        <a [href]="'https://' + follow.following.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
-          {{ follow.following.host }}
+        <a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
+          {{ follow.following.name + '@' + follow.following.host }}
           <span class="glyphicon glyphicon-new-window"></span>
         </a>
       </td>
@@ -57,6 +57,7 @@
       <td>{{ follow.createdAt | date: 'short' }}</td>
       <td>
         <my-redundancy-checkbox
+          *ngIf="isInstanceFollowing(follow)"
           [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
         ></my-redundancy-checkbox>
       </td>
   </ng-template>
 </p-table>
 
-<my-batch-domains-modal #batchDomainsModal i18n-action action="Follow domains" (domains)="addFollowing($event)">
-  <ng-container ngProjectAs="warning">
-    <div i18n *ngIf="httpEnabled() === false"  class="alert alert-warning">
-      It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
-    </div>
-  </ng-container>
-</my-batch-domains-modal>
+<my-follow-modal #followModal></my-follow-modal>
index b63fe08c0b4b36f0854f9da306836f02338f8492..ba62dfa231a3d6a6fecfc027cf98e98d7c538b97 100644 (file)
@@ -4,13 +4,14 @@ import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
 import { InstanceFollowService } from '@app/shared/shared-instance'
 import { BatchDomainsModalComponent } from '@app/shared/shared-moderation'
 import { ActorFollow } from '@shared/models'
+import { FollowModalComponent } from './follow-modal.component'
 
 @Component({
   templateUrl: './following-list.component.html',
   styleUrls: [ '../follows.component.scss', './following-list.component.scss' ]
 })
 export class FollowingListComponent extends RestTable implements OnInit {
-  @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
+  @ViewChild('followModal') followModal: FollowModalComponent
 
   following: ActorFollow[] = []
   totalRecords = 0
@@ -33,23 +34,12 @@ export class FollowingListComponent extends RestTable implements OnInit {
     return 'FollowingListComponent'
   }
 
-  addDomainsToFollow () {
-    this.batchDomainsModal.openModal()
+  openFollowModal () {
+    this.followModal.openModal()
   }
 
-  httpEnabled () {
-    return window.location.protocol === 'https:'
-  }
-
-  async addFollowing (hosts: string[]) {
-    this.followService.follow(hosts).subscribe(
-      () => {
-        this.notifier.success($localize`Follow request(s) sent!`)
-        this.reloadData()
-      },
-
-      err => this.notifier.error(err.message)
-    )
+  isInstanceFollowing (follow: ActorFollow) {
+    return follow.following.name === 'peertube'
   }
 
   async removeFollowing (follow: ActorFollow) {
index a70d46a7e6138f1c72285d93776fb9d1229be39c..88be0ed4cd8c0c73d571056eb520eab65365c1fd 100644 (file)
@@ -1 +1,2 @@
+export * from './follow-modal.component'
 export * from './following-list.component'
index cd70daf77dc439dfa740c45dd8561c3b9df6d124..3843b42b5e14a2d1a4c7372ab747f0b5d7424aaf 100644 (file)
@@ -25,7 +25,7 @@ export const FollowsRoutes: Routes = [
         component: FollowingListComponent,
         data: {
           meta: {
-            title: $localize`Following list`
+            title: $localize`Following`
           }
         }
       },
@@ -34,7 +34,7 @@ export const FollowsRoutes: Routes = [
         component: FollowersListComponent,
         data: {
           meta: {
-            title: $localize`Followers list`
+            title: $localize`Followers`
           }
         }
       },
diff --git a/client/src/app/shared/form-validators/batch-domains-validators.ts b/client/src/app/shared/form-validators/batch-domains-validators.ts
deleted file mode 100644 (file)
index 423d133..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-import { AbstractControl, FormControl, ValidatorFn, Validators } from '@angular/forms'
-import { BuildFormValidator } from './form-validator.model'
-import { validateHost } from './host'
-
-export function getNotEmptyHosts (hosts: string) {
-  return hosts
-    .split('\n')
-    .filter((host: string) => host && host.length !== 0) // Eject empty hosts
-}
-
-const validDomains: ValidatorFn = (control: FormControl) => {
-  if (!control.value) return null
-
-  const newHostsErrors = []
-  const hosts = getNotEmptyHosts(control.value)
-
-  for (const host of hosts) {
-    if (validateHost(host) === false) {
-      newHostsErrors.push($localize`${host} is not valid`)
-    }
-  }
-
-  /* Is not valid. */
-  if (newHostsErrors.length !== 0) {
-    return {
-      'validDomains': {
-        reason: 'invalid',
-        value: newHostsErrors.join('. ') + '.'
-      }
-    }
-  }
-
-  /* Is valid. */
-  return null
-}
-
-const isHostsUnique: ValidatorFn = (control: AbstractControl) => {
-  if (!control.value) return null
-
-  const hosts = getNotEmptyHosts(control.value)
-
-  if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
-    return null
-  } else {
-    return {
-      'uniqueDomains': {
-        reason: 'invalid'
-      }
-    }
-  }
-}
-
-export const DOMAINS_VALIDATOR: BuildFormValidator = {
-  VALIDATORS: [Validators.required, validDomains, isHostsUnique],
-  MESSAGES: {
-    'required': $localize`Domain is required.`,
-    'validDomains': $localize`Domains entered are invalid.`,
-    'uniqueDomains': $localize`Domains entered contain duplicates.`
-  }
-}
diff --git a/client/src/app/shared/form-validators/host-validators.ts b/client/src/app/shared/form-validators/host-validators.ts
new file mode 100644 (file)
index 0000000..d750113
--- /dev/null
@@ -0,0 +1,105 @@
+import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
+import { BuildFormValidator } from './form-validator.model'
+
+function validateHost (value: string) {
+  // Thanks to http://stackoverflow.com/a/106223
+  const HOST_REGEXP = new RegExp(
+    '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
+  )
+
+  return HOST_REGEXP.test(value)
+}
+
+function validateHandle (value: string) {
+  if (!value) return false
+
+  return value.includes('@')
+}
+
+const validHosts: ValidatorFn = (control: AbstractControl) => {
+  if (!control.value) return null
+
+  const errors = []
+  const hosts = splitAndGetNotEmpty(control.value)
+
+  for (const host of hosts) {
+    if (validateHost(host) === false) {
+      errors.push($localize`${host} is not valid`)
+    }
+  }
+
+  // valid
+  if (errors.length === 0) return null
+
+  return {
+    'validHosts': {
+      reason: 'invalid',
+      value: errors.join('. ') + '.'
+    }
+  }
+}
+
+const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => {
+  if (!control.value) return null
+
+  const errors = []
+  const lines = splitAndGetNotEmpty(control.value)
+
+  for (const line of lines) {
+    if (validateHost(line) === false && validateHandle(line) === false) {
+      errors.push($localize`${line} is not valid`)
+    }
+  }
+
+  // valid
+  if (errors.length === 0) return null
+
+  return {
+    'validHostsOrHandles': {
+      reason: 'invalid',
+      value: errors.join('. ') + '.'
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export function splitAndGetNotEmpty (value: string) {
+  return value
+    .split('\n')
+    .filter(line => line && line.length !== 0) // Eject empty hosts
+}
+
+export const unique: ValidatorFn = (control: AbstractControl) => {
+  if (!control.value) return null
+
+  const hosts = splitAndGetNotEmpty(control.value)
+
+  if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
+    return null
+  }
+
+  return {
+    'unique': {
+      reason: 'invalid'
+    }
+  }
+}
+
+export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, validHosts, unique ],
+  MESSAGES: {
+    'required': $localize`Domain is required.`,
+    'validHosts': $localize`Hosts entered are invalid.`,
+    'unique': $localize`Hosts entered contain duplicates.`
+  }
+}
+
+export const UNIQUE_HOSTS_OR_HANDLE_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, validHostsOrHandles, unique ],
+  MESSAGES: {
+    'required': $localize`Domain is required.`,
+    'validHostsOrHandles': $localize`Hosts or handles are invalid.`,
+    'unique': $localize`Hosts or handles contain duplicates.`
+  }
+}
diff --git a/client/src/app/shared/form-validators/host.ts b/client/src/app/shared/form-validators/host.ts
deleted file mode 100644 (file)
index c18a35f..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-export function validateHost (value: string) {
-  // Thanks to http://stackoverflow.com/a/106223
-  const HOST_REGEXP = new RegExp(
-    '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
-  )
-
-  return HOST_REGEXP.test(value)
-}
index f621f03a43d6facf88b9a48a18e1ec95a70968f5..c14272a2a505ca9665601c8f01ee6b99144a7df4 100644 (file)
@@ -1,5 +1,4 @@
 export * from './form-validator.model'
-export * from './host'
 
 // Don't re export const variables because webpack 4 cannot do tree shaking with them
 // export * from './abuse-validators'
index e5266014067f2814643a620f1b64d7f991096260..af44020cf4ef1df622ea2fe64fa07b9d941e1864 100644 (file)
@@ -4,7 +4,7 @@ import { catchError, map } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, RestPagination, RestService } from '@app/core'
-import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/models'
+import { ActivityPubActorType, ActorFollow, FollowState, ResultList, ServerFollowCreate } from '@shared/models'
 import { environment } from '../../../environments/environment'
 
 @Injectable()
@@ -64,9 +64,10 @@ export class InstanceFollowService {
                )
   }
 
-  follow (notEmptyHosts: string[]) {
-    const body = {
-      hosts: notEmptyHosts
+  follow (hostsOrHandles: string[]) {
+    const body: ServerFollowCreate = {
+      handles: hostsOrHandles.filter(v => v.includes('@')),
+      hosts: hostsOrHandles.filter(v => !v.includes('@'))
     }
 
     return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body)
@@ -77,7 +78,9 @@ export class InstanceFollowService {
   }
 
   unfollow (follow: ActorFollow) {
-    return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host)
+    const handle = follow.following.name + '@' + follow.following.host
+
+    return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + handle)
                .pipe(
                  map(this.restExtractor.extractDataBool),
                  catchError(res => this.restExtractor.handleError(res))
index 6a3c657213606f013aff827fcb89257d6b56c148..8306a96bce3508fcaaf988f0c1d54dd6208c4652 100644 (file)
@@ -1,6 +1,6 @@
 <ng-template #modal>
   <div class="modal-header">
-    <h4 i18n class="modal-title">{{ action }}</h4>
+    <h4 class="modal-title">{{ action }}</h4>
 
     <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
   </div>
         <label i18n for="hosts">1 host (without "http://") per line</label>
 
         <textarea
-          [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
-          class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
+          [placeholder]="placeholder" formControlName="hosts" type="text" id="hosts" name="hosts"
+          class="form-control" [ngClass]="{ 'input-error': formErrors['hosts'] }" ngbAutofocus
         ></textarea>
 
-        <div *ngIf="formErrors.domains" class="form-error">
-          {{ formErrors.domains }}
+        <div *ngIf="formErrors.hosts" class="form-error">
+          {{ formErrors.hosts }}
 
-          <div *ngIf="form.controls['domains'].errors.validDomains">
-            {{ form.controls['domains'].errors.validDomains.value }}
+          <div *ngIf="form.controls['hosts'].errors.validHosts">
+            {{ form.controls['hosts'].errors.validHosts.value }}
           </div>
         </div>
       </div>
index 6edbb602328380ec11dbab454554818b233d3659..20be728f6c9bcabfb8a258aff4736abf143b1c8b 100644 (file)
@@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angu
 import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { DOMAINS_VALIDATOR, getNotEmptyHosts } from '../form-validators/batch-domains-validators'
+import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
 
 @Component({
   selector: 'my-batch-domains-modal',
@@ -28,7 +28,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
     if (!this.action) this.action = $localize`Process domains`
 
     this.buildForm({
-      domains: DOMAINS_VALIDATOR
+      hosts: UNIQUE_HOSTS_VALIDATOR
     })
   }
 
@@ -41,9 +41,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
   }
 
   submit () {
-    this.domains.emit(
-      getNotEmptyHosts(this.form.controls['domains'].value)
-    )
+    this.domains.emit(splitAndGetNotEmpty(this.form.controls['hosts'].value))
     this.form.reset()
     this.hide()
   }
index e6f4f6b92f5cb0ceb9320ef3ece96790f33148a4..cbe6b7e4f5c5a75237d66ca8b1eeeb51454b9c6f 100644 (file)
@@ -29,6 +29,7 @@ import {
   removeFollowingValidator
 } from '../../../middlewares/validators'
 import { ActorFollowModel } from '../../../models/actor/actor-follow'
+import { ServerFollowCreate } from '@shared/models'
 
 const serverFollowsRouter = express.Router()
 serverFollowsRouter.get('/following',
@@ -45,10 +46,10 @@ serverFollowsRouter.post('/following',
   ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
   followValidator,
   setBodyHostsPort,
-  asyncMiddleware(followInstance)
+  asyncMiddleware(addFollow)
 )
 
-serverFollowsRouter.delete('/following/:host',
+serverFollowsRouter.delete('/following/:hostOrHandle',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
   asyncMiddleware(removeFollowingValidator),
@@ -125,8 +126,8 @@ async function listFollowers (req: express.Request, res: express.Response) {
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
 
-async function followInstance (req: express.Request, res: express.Response) {
-  const hosts = req.body.hosts as string[]
+async function addFollow (req: express.Request, res: express.Response) {
+  const { hosts, handles } = req.body as ServerFollowCreate
   const follower = await getServerActor()
 
   for (const host of hosts) {
@@ -139,6 +140,18 @@ async function followInstance (req: express.Request, res: express.Response) {
     JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
   }
 
+  for (const handle of handles) {
+    const [ name, host ] = handle.split('@')
+
+    const payload = {
+      host,
+      name,
+      followerActorId: follower.id
+    }
+
+    JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
+  }
+
   return res.status(HttpStatusCode.NO_CONTENT_204).end()
 }
 
index fbef7ad8712d58a746eb2ea9671de86be91d16a8..8f65552c33ba48525972c0f6859076852b97dda6 100644 (file)
@@ -1,4 +1,4 @@
-import { exists } from './misc'
+import { exists, isArray } from './misc'
 import { FollowState } from '@shared/models'
 
 function isFollowStateValid (value: FollowState) {
@@ -7,8 +7,24 @@ function isFollowStateValid (value: FollowState) {
   return value === 'pending' || value === 'accepted'
 }
 
+function isRemoteHandleValid (value: string) {
+  if (!exists(value)) return false
+  if (typeof value !== 'string') return false
+
+  return value.includes('@')
+}
+
+function isEachUniqueHandleValid (handles: string[]) {
+  return isArray(handles) &&
+    handles.every(handle => {
+      return isRemoteHandleValid(handle) && handles.indexOf(handle) === handles.lastIndexOf(handle)
+    })
+}
+
 // ---------------------------------------------------------------------------
 
 export {
-  isFollowStateValid
+  isFollowStateValid,
+  isRemoteHandleValid,
+  isEachUniqueHandleValid
 }
index adf1ea497e17ae092d73f8e00b74a55868cf7db5..c0f8b6aebb23219e86f0932ab13a0487822eb2a9 100644 (file)
@@ -19,7 +19,6 @@ function isHostValid (host: string) {
 
 function isEachUniqueHostValid (hosts: string[]) {
   return isArray(hosts) &&
-    hosts.length !== 0 &&
     hosts.every(host => {
       return isHostValid(host) && hosts.indexOf(host) === hosts.lastIndexOf(host)
     })
index cd117f5712896e81ce374722723353a0e07ebbc8..28ff5225a01bde45b7ddf7b0e5086487bc713386 100644 (file)
@@ -1,3 +1,4 @@
+import { retryTransactionWrapper } from '@server/helpers/database-utils'
 import * as Bluebird from 'bluebird'
 import { URL } from 'url'
 import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
@@ -51,7 +52,7 @@ async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction
     }
   }
 
-  if (cleaner) await cleaner(startDate)
+  if (cleaner) await retryTransactionWrapper(cleaner, startDate)
 }
 
 export {
index c1bd667e04e1def55d048eeca0b5cf4b5aa16a5f..741b54df51be336ab0c988bd72d6d3dfcab4f23d 100644 (file)
@@ -31,6 +31,21 @@ async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transact
   }
 }
 
+// If we only have an host, use a default account handle
+function getRemoteNameAndHost (handleOrHost: string) {
+  let name = SERVER_ACTOR_NAME
+  let host = handleOrHost
+
+  const splitted = handleOrHost.split('@')
+  if (splitted.length === 2) {
+    name = splitted[0]
+    host = splitted[1]
+  }
+
+  return { name, host }
+}
+
 export {
-  autoFollowBackIfNeeded
+  autoFollowBackIfNeeded,
+  getRemoteNameAndHost
 }
index 05cc66c38db07a282aceccf00606d643bf16d8ed..16abdd096c948a44777de807988134c41543726a 100644 (file)
@@ -1,7 +1,8 @@
 import * as express from 'express'
 import { body, param, query } from 'express-validator'
-import { isFollowStateValid } from '@server/helpers/custom-validators/follows'
+import { isEachUniqueHandleValid, isFollowStateValid, isRemoteHandleValid } from '@server/helpers/custom-validators/follows'
 import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors'
+import { getRemoteNameAndHost } from '@server/lib/activitypub/follow'
 import { getServerActor } from '@server/models/application/application'
 import { MActorFollowActorsDefault } from '@server/types/models'
 import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
@@ -9,10 +10,11 @@ import { isTestInstance } from '../../helpers/core-utils'
 import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
 import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
 import { logger } from '../../helpers/logger'
-import { SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
+import { WEBSERVER } from '../../initializers/constants'
 import { ActorModel } from '../../models/actor/actor'
 import { ActorFollowModel } from '../../models/actor/actor-follow'
 import { areValidationErrors } from './shared'
+import { ServerFollowCreate } from '@shared/models'
 
 const listFollowsValidator = [
   query('state')
@@ -30,29 +32,46 @@ const listFollowsValidator = [
 ]
 
 const followValidator = [
-  body('hosts').custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'),
+  body('hosts')
+    .toArray()
+    .custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'),
+
+  body('handles')
+    .toArray()
+    .custom(isEachUniqueHandleValid).withMessage('Should have an array of handles'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    // Force https if the administrator wants to make friends
+    // Force https if the administrator wants to follow remote actors
     if (isTestInstance() === false && WEBSERVER.SCHEME === 'http') {
       return res
         .status(HttpStatusCode.INTERNAL_SERVER_ERROR_500)
         .json({
           error: 'Cannot follow on a non HTTPS web server.'
         })
-        .end()
     }
 
     logger.debug('Checking follow parameters', { parameters: req.body })
 
     if (areValidationErrors(req, res)) return
 
+    const body: ServerFollowCreate = req.body
+    if (body.hosts.length === 0 && body.handles.length === 0) {
+
+      return res
+        .status(HttpStatusCode.BAD_REQUEST_400)
+        .json({
+          error: 'You must provide at least one handle or one host.'
+        })
+    }
+
     return next()
   }
 ]
 
 const removeFollowingValidator = [
-  param('host').custom(isHostValid).withMessage('Should have a valid host'),
+  param('hostOrHandle')
+    .custom(value => isHostValid(value) || isRemoteHandleValid(value))
+    .withMessage('Should have a valid host/handle'),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking unfollowing parameters', { parameters: req.params })
@@ -60,12 +79,14 @@ const removeFollowingValidator = [
     if (areValidationErrors(req, res)) return
 
     const serverActor = await getServerActor()
-    const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
+
+    const { name, host } = getRemoteNameAndHost(req.params.hostOrHandle)
+    const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, name, host)
 
     if (!follow) {
       return res.fail({
         status: HttpStatusCode.NOT_FOUND_404,
-        message: `Following ${req.params.host} not found.`
+        message: `Follow ${req.params.hostOrHandle} not found.`
       })
     }
 
index 3a09e51d6feffae7bd59ddba02cb7a9af01b8afb..83c00a22da40d9b5caa7be7fbbcb7f0c33263020 100644 (file)
@@ -324,13 +324,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
 
     const followWhere = state ? { state } : {}
     const followingWhere: WhereOptions = {}
-    const followingServerWhere: WhereOptions = {}
 
     if (search) {
-      Object.assign(followingServerWhere, {
-        host: {
-          [Op.iLike]: '%' + search + '%'
-        }
+      Object.assign(followWhere, {
+        [Op.or]: [
+          searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
+          searchAttribute(options.search, '$ActorFollowing.Server.host$')
+        ]
       })
     }
 
@@ -361,8 +361,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
           include: [
             {
               model: ServerModel,
-              required: true,
-              where: followingServerWhere
+              required: true
             }
           ]
         }
@@ -391,13 +390,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
 
     const followWhere = state ? { state } : {}
     const followerWhere: WhereOptions = {}
-    const followerServerWhere: WhereOptions = {}
 
     if (search) {
-      Object.assign(followerServerWhere, {
-        host: {
-          [Op.iLike]: '%' + search + '%'
-        }
+      Object.assign(followWhere, {
+        [Op.or]: [
+          searchAttribute(search, '$ActorFollower.preferredUsername$'),
+          searchAttribute(search, '$ActorFollower.Server.host$')
+        ]
       })
     }
 
@@ -420,8 +419,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
           include: [
             {
               model: ServerModel,
-              required: true,
-              where: followerServerWhere
+              required: true
             }
           ]
         },
index 30b251f0f33ce3123922ca1f6fb143a8dd0ff44b..054f71c8c0e48a5b5166ebef9d2c4da93edbb562 100644 (file)
@@ -304,16 +304,16 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
   private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) {
     let query =
     '(' +
-    '  EXISTS (' +
+    '  EXISTS (' + // Videos shared by actors we follow
     '    SELECT 1 FROM "videoShare" ' +
     '    INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
     '    AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
     '    WHERE "videoShare"."videoId" = "video"."id"' +
     '  )' +
     '  OR' +
-    '  EXISTS (' +
+    '  EXISTS (' + // Videos published by accounts we follow
     '    SELECT 1 from "actorFollow" ' +
-    '    WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
+    '    WHERE "actorFollow"."targetActorId" = "account"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
     '    AND "actorFollow"."state" = \'accepted\'' +
     '  )'
 
index dfe3f226d4e075a5708e6835f768ba598599c912..2bc9f6b968175d10a982ba15cc3456a1860b1961 100644 (file)
@@ -32,19 +32,13 @@ describe('Test server follows API validators', function () {
     let userAccessToken = null
 
     before(async function () {
-      const user = {
-        username: 'user1',
-        password: 'password'
-      }
-
-      await server.users.create({ username: user.username, password: user.password })
-      userAccessToken = await server.login.getAccessToken(user)
+      userAccessToken = await server.users.generateUserAndToken('user1')
     })
 
     describe('When adding follows', function () {
       const path = '/api/v1/server/following'
 
-      it('Should fail without hosts', async function () {
+      it('Should fail with nothing', async function () {
         await makePostBodyRequest({
           url: server.url,
           path,
@@ -53,41 +47,51 @@ describe('Test server follows API validators', function () {
         })
       })
 
-      it('Should fail if hosts is not an array', async function () {
+      it('Should fail if hosts is not composed by hosts', async function () {
         await makePostBodyRequest({
           url: server.url,
           path,
+          fields: { hosts: [ 'localhost:9002', 'localhost:coucou' ] },
           token: server.accessToken,
-          fields: { hosts: 'localhost:9002' },
           expectedStatus: HttpStatusCode.BAD_REQUEST_400
         })
       })
 
-      it('Should fail if the array is not composed by hosts', async function () {
+      it('Should fail if hosts is composed with http schemes', async function () {
         await makePostBodyRequest({
           url: server.url,
           path,
-          fields: { hosts: [ 'localhost:9002', 'localhost:coucou' ] },
+          fields: { hosts: [ 'localhost:9002', 'http://localhost:9003' ] },
           token: server.accessToken,
           expectedStatus: HttpStatusCode.BAD_REQUEST_400
         })
       })
 
-      it('Should fail if the array is composed with http schemes', async function () {
+      it('Should fail if hosts are not unique', async function () {
         await makePostBodyRequest({
           url: server.url,
           path,
-          fields: { hosts: [ 'localhost:9002', 'http://localhost:9003' ] },
+          fields: { urls: [ 'localhost:9002', 'localhost:9002' ] },
           token: server.accessToken,
           expectedStatus: HttpStatusCode.BAD_REQUEST_400
         })
       })
 
-      it('Should fail if hosts are not unique', async function () {
+      it('Should fail if handles is not composed by handles', async function () {
         await makePostBodyRequest({
           url: server.url,
           path,
-          fields: { urls: [ 'localhost:9002', 'localhost:9002' ] },
+          fields: { handles: [ 'hello@example.com', 'localhost:9001' ] },
+          token: server.accessToken,
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should fail if handles are not unique', async function () {
+        await makePostBodyRequest({
+          url: server.url,
+          path,
+          fields: { urls: [ 'hello@example.com', 'hello@example.com' ] },
           token: server.accessToken,
           expectedStatus: HttpStatusCode.BAD_REQUEST_400
         })
index 8ed5ad9e58f0429ea98a4ec0784e5e21bec4f04c..089af8b159c0f45e198a8082ff4242c15277064c 100644 (file)
@@ -690,7 +690,7 @@ describe('Test blocklist', function () {
           const now = new Date()
           await servers[1].follows.unfollow({ target: servers[0] })
           await waitJobs(servers)
-          await servers[1].follows.follow({ targets: [ servers[0].host ] })
+          await servers[1].follows.follow({ hosts: [ servers[0].host ] })
 
           await waitJobs(servers)
 
@@ -751,7 +751,7 @@ describe('Test blocklist', function () {
           const now = new Date()
           await servers[1].follows.unfollow({ target: servers[0] })
           await waitJobs(servers)
-          await servers[1].follows.follow({ targets: [ servers[0].host ] })
+          await servers[1].follows.follow({ hosts: [ servers[0].host ] })
 
           await waitJobs(servers)
 
index 6e8f8a2b4b78345d2d0aa16d88c45404a415212e..6f74709b312aa34068a9759ceec81df9f89824e1 100644 (file)
@@ -368,7 +368,7 @@ describe('Test moderation notifications', function () {
     it('Should send a notification only to admin when there is a new instance follower', async function () {
       this.timeout(20000)
 
-      await servers[2].follows.follow({ targets: [ servers[0].url ] })
+      await servers[2].follows.follow({ hosts: [ servers[0].url ] })
 
       await waitJobs(servers)
 
@@ -393,7 +393,7 @@ describe('Test moderation notifications', function () {
       }
       await servers[0].config.updateCustomSubConfig({ newConfig: config })
 
-      await servers[2].follows.follow({ targets: [ servers[0].url ] })
+      await servers[2].follows.follow({ hosts: [ servers[0].url ] })
 
       await waitJobs(servers)
 
index 25cd11658d48c80ebc998b90aa726b14cba0ad3d..933a2c7760c1f881f9582f0c7127f7ba40b2d983 100644 (file)
@@ -75,7 +75,7 @@ describe('Test redundancy constraints', function () {
     await waitJobs(servers)
 
     // Server 1 and server 2 follow each other
-    await remoteServer.follows.follow({ targets: [ localServer.url ] })
+    await remoteServer.follows.follow({ hosts: [ localServer.url ] })
     await waitJobs(servers)
     await remoteServer.redundancy.updateRedundancy({ host: localServer.host, redundancyAllowed: true })
 
@@ -161,7 +161,7 @@ describe('Test redundancy constraints', function () {
   it('Should have redundancy on server 1 and on server 2 with followings filter now server 2 follows server 1', async function () {
     this.timeout(120000)
 
-    await localServer.follows.follow({ targets: [ remoteServer.url ] })
+    await localServer.follows.follow({ hosts: [ remoteServer.url ] })
     await waitJobs(servers)
 
     await uploadWrapper('video 4 server 2')
index 8dca2e5e540985e9db8affe0070061acd00235c6..ce7b519252517f490bcf891aa31cce117798e8ad 100644 (file)
@@ -33,7 +33,7 @@ async function checkFollow (follower: PeerTubeServer, following: PeerTubeServer,
 }
 
 async function server1Follows2 (servers: PeerTubeServer[]) {
-  await servers[0].follows.follow({ targets: [ servers[1].host ] })
+  await servers[0].follows.follow({ hosts: [ servers[1].host ] })
 
   await waitJobs(servers)
 }
index 0aa328c5aeb7c34a917d4ecb33f797df14bfd0a2..921f510435cf3131e6bd138b4481c79f24660ce9 100644 (file)
@@ -60,7 +60,7 @@ describe('Test follows moderation', function () {
   it('Should have server 1 following server 2', async function () {
     this.timeout(30000)
 
-    await commands[0].follow({ targets: [ servers[1].url ] })
+    await commands[0].follow({ hosts: [ servers[1].url ] })
 
     await waitJobs(servers)
   })
@@ -95,7 +95,7 @@ describe('Test follows moderation', function () {
 
     await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
 
-    await commands[0].follow({ targets: [ servers[1].url ] })
+    await commands[0].follow({ hosts: [ servers[1].url ] })
     await waitJobs(servers)
 
     await checkNoFollowers(servers)
@@ -115,7 +115,7 @@ describe('Test follows moderation', function () {
 
     await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
 
-    await commands[0].follow({ targets: [ servers[1].url ] })
+    await commands[0].follow({ hosts: [ servers[1].url ] })
     await waitJobs(servers)
 
     await checkServer1And2HasFollowers(servers)
@@ -139,7 +139,7 @@ describe('Test follows moderation', function () {
     await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
     await servers[2].config.updateCustomSubConfig({ newConfig: subConfig })
 
-    await commands[0].follow({ targets: [ servers[1].url ] })
+    await commands[0].follow({ hosts: [ servers[1].url ] })
     await waitJobs(servers)
 
     await checkServer1And2HasFollowers(servers, 'pending')
@@ -157,7 +157,7 @@ describe('Test follows moderation', function () {
   it('Should reject another follower', async function () {
     this.timeout(20000)
 
-    await commands[0].follow({ targets: [ servers[2].url ] })
+    await commands[0].follow({ hosts: [ servers[2].url ] })
     await waitJobs(servers)
 
     {
index ff8f880a65be9ae1f747c4b6d3210787a6aa156b..a616edcfff89eeb403a99330d3e43212162f91b5 100644 (file)
@@ -8,308 +8,369 @@ import {
   createMultipleServers,
   dateIsValid,
   expectAccountFollows,
-  FollowsCommand,
+  expectChannelsFollows,
   PeerTubeServer,
   setAccessTokensToServers,
   testCaptionFile,
   waitJobs
 } from '@shared/extra-utils'
-import { Video, VideoPrivacy } from '@shared/models'
+import { VideoCreateResult, VideoPrivacy } from '@shared/models'
 
 const expect = chai.expect
 
 describe('Test follows', function () {
   let servers: PeerTubeServer[] = []
-  let followsCommands: FollowsCommand[]
 
   before(async function () {
     this.timeout(30000)
 
     servers = await createMultipleServers(3)
-    followsCommands = servers.map(s => s.follows)
 
     // Get the access tokens
     await setAccessTokensToServers(servers)
   })
 
-  it('Should not have followers', async function () {
-    for (const server of servers) {
-      const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' })
-      expect(body.total).to.equal(0)
-
-      const follows = body.data
-      expect(follows).to.be.an('array')
-      expect(follows.length).to.equal(0)
-    }
-  })
+  describe('Data propagation after follow', function () {
 
-  it('Should not have following', async function () {
-    for (const server of servers) {
-      const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
-      expect(body.total).to.equal(0)
+    it('Should not have followers/followings', async function () {
+      for (const server of servers) {
+        const bodies = await Promise.all([
+          server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }),
+          server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
+        ])
 
-      const follows = body.data
-      expect(follows).to.be.an('array')
-      expect(follows.length).to.equal(0)
-    }
-  })
+        for (const body of bodies) {
+          expect(body.total).to.equal(0)
 
-  it('Should have server 1 following server 2 and 3', async function () {
-    this.timeout(30000)
+          const follows = body.data
+          expect(follows).to.be.an('array')
+          expect(follows).to.have.lengthOf(0)
+        }
+      }
+    })
 
-    await followsCommands[0].follow({ targets: [ servers[1].url, servers[2].url ] })
+    it('Should have server 1 following root account of server 2 and server 3', async function () {
+      this.timeout(30000)
 
-    await waitJobs(servers)
-  })
+      await servers[0].follows.follow({
+        hosts: [ servers[2].url ],
+        handles: [ 'root@' + servers[1].host ]
+      })
 
-  it('Should have 2 followings on server 1', async function () {
-    const body = await followsCommands[0].getFollowings({ start: 0, count: 1, sort: 'createdAt' })
-    expect(body.total).to.equal(2)
+      await waitJobs(servers)
+    })
 
-    let follows = body.data
-    expect(follows).to.be.an('array')
-    expect(follows.length).to.equal(1)
+    it('Should have 2 followings on server 1', async function () {
+      const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' })
+      expect(body.total).to.equal(2)
 
-    const body2 = await followsCommands[0].getFollowings({ start: 1, count: 1, sort: 'createdAt' })
-    follows = follows.concat(body2.data)
+      let follows = body.data
+      expect(follows).to.be.an('array')
+      expect(follows).to.have.lengthOf(1)
 
-    const server2Follow = follows.find(f => f.following.host === 'localhost:' + servers[1].port)
-    const server3Follow = follows.find(f => f.following.host === 'localhost:' + servers[2].port)
+      const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' })
+      follows = follows.concat(body2.data)
 
-    expect(server2Follow).to.not.be.undefined
-    expect(server3Follow).to.not.be.undefined
-    expect(server2Follow.state).to.equal('accepted')
-    expect(server3Follow.state).to.equal('accepted')
-  })
+      const server2Follow = follows.find(f => f.following.host === servers[1].host)
+      const server3Follow = follows.find(f => f.following.host === servers[2].host)
 
-  it('Should search/filter followings on server 1', async function () {
-    const sort = 'createdAt'
-    const start = 0
-    const count = 1
+      expect(server2Follow).to.not.be.undefined
+      expect(server2Follow.following.name).to.equal('root')
+      expect(server2Follow.state).to.equal('accepted')
 
-    {
-      const search = ':' + servers[1].port
+      expect(server3Follow).to.not.be.undefined
+      expect(server3Follow.following.name).to.equal('peertube')
+      expect(server3Follow.state).to.equal('accepted')
+    })
 
-      {
-        const body = await followsCommands[0].getFollowings({ start, count, sort, search })
-        expect(body.total).to.equal(1)
+    it('Should have 0 followings on server 2 and 3', async function () {
+      for (const server of [ servers[1], servers[2] ]) {
+        const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
+        expect(body.total).to.equal(0)
 
         const follows = body.data
-        expect(follows.length).to.equal(1)
-        expect(follows[0].following.host).to.equal('localhost:' + servers[1].port)
+        expect(follows).to.be.an('array')
+        expect(follows).to.have.lengthOf(0)
       }
+    })
 
-      {
-        const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted' })
-        expect(body.total).to.equal(1)
-        expect(body.data).to.have.lengthOf(1)
+    it('Should have 1 followers on server 3', async function () {
+      const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
+      expect(body.total).to.equal(1)
+
+      const follows = body.data
+      expect(follows).to.be.an('array')
+      expect(follows).to.have.lengthOf(1)
+      expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port)
+    })
+
+    it('Should have 0 followers on server 1 and 2', async function () {
+      for (const server of [ servers[0], servers[1] ]) {
+        const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' })
+        expect(body.total).to.equal(0)
+
+        const follows = body.data
+        expect(follows).to.be.an('array')
+        expect(follows).to.have.lengthOf(0)
       }
+    })
+
+    it('Should search/filter followings on server 1', async function () {
+      const sort = 'createdAt'
+      const start = 0
+      const count = 1
 
       {
-        const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
-        expect(body.total).to.equal(0)
-        expect(body.data).to.have.lengthOf(0)
+        const search = ':' + servers[1].port
+
+        {
+          const body = await servers[0].follows.getFollowings({ start, count, sort, search })
+          expect(body.total).to.equal(1)
+
+          const follows = body.data
+          expect(follows).to.have.lengthOf(1)
+          expect(follows[0].following.host).to.equal(servers[1].host)
+        }
+
+        {
+          const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' })
+          expect(body.total).to.equal(1)
+          expect(body.data).to.have.lengthOf(1)
+        }
+
+        {
+          const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
+          expect(body.total).to.equal(1)
+          expect(body.data).to.have.lengthOf(1)
+        }
+
+        {
+          const body = await servers[0].follows.getFollowings({
+            start,
+            count,
+            sort,
+            search,
+            state: 'accepted',
+            actorType: 'Application'
+          })
+          expect(body.total).to.equal(0)
+          expect(body.data).to.have.lengthOf(0)
+        }
+
+        {
+          const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' })
+          expect(body.total).to.equal(0)
+          expect(body.data).to.have.lengthOf(0)
+        }
       }
 
       {
-        const body = await followsCommands[0].getFollowings({
-          start,
-          count,
-          sort,
-          search,
-          state: 'accepted',
-          actorType: 'Application'
-        })
+        const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' })
         expect(body.total).to.equal(1)
         expect(body.data).to.have.lengthOf(1)
       }
 
       {
-        const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'pending' })
+        const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' })
         expect(body.total).to.equal(0)
+
         expect(body.data).to.have.lengthOf(0)
       }
-    }
+    })
 
-    {
-      const body = await followsCommands[0].getFollowings({ start, count, sort, search: 'bla' })
-      expect(body.total).to.equal(0)
+    it('Should search/filter followers on server 2', async function () {
+      const start = 0
+      const count = 5
+      const sort = 'createdAt'
 
-      expect(body.data.length).to.equal(0)
-    }
-  })
+      {
+        const search = servers[0].port + ''
 
-  it('Should have 0 followings on server 2 and 3', async function () {
-    for (const server of [ servers[1], servers[2] ]) {
-      const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
-      expect(body.total).to.equal(0)
+        {
+          const body = await servers[2].follows.getFollowers({ start, count, sort, search })
+          expect(body.total).to.equal(1)
 
-      const follows = body.data
-      expect(follows).to.be.an('array')
-      expect(follows.length).to.equal(0)
-    }
-  })
+          const follows = body.data
+          expect(follows).to.have.lengthOf(1)
+          expect(follows[0].following.host).to.equal(servers[2].host)
+        }
 
-  it('Should have 1 followers on server 2 and 3', async function () {
-    for (const server of [ servers[1], servers[2] ]) {
-      const body = await server.follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
-      expect(body.total).to.equal(1)
+        {
+          const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' })
+          expect(body.total).to.equal(1)
+          expect(body.data).to.have.lengthOf(1)
+        }
 
-      const follows = body.data
-      expect(follows).to.be.an('array')
-      expect(follows.length).to.equal(1)
-      expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port)
-    }
-  })
+        {
+          const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
+          expect(body.total).to.equal(0)
+          expect(body.data).to.have.lengthOf(0)
+        }
 
-  it('Should search/filter followers on server 2', async function () {
-    const start = 0
-    const count = 5
-    const sort = 'createdAt'
+        {
+          const body = await servers[2].follows.getFollowers({
+            start,
+            count,
+            sort,
+            search,
+            state: 'accepted',
+            actorType: 'Application'
+          })
+          expect(body.total).to.equal(1)
+          expect(body.data).to.have.lengthOf(1)
+        }
 
-    {
-      const search = servers[0].port + ''
+        {
+          const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' })
+          expect(body.total).to.equal(0)
+          expect(body.data).to.have.lengthOf(0)
+        }
+      }
 
       {
-        const body = await followsCommands[2].getFollowers({ start, count, sort, search })
-        expect(body.total).to.equal(1)
+        const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' })
+        expect(body.total).to.equal(0)
 
         const follows = body.data
-        expect(follows.length).to.equal(1)
-        expect(follows[0].following.host).to.equal('localhost:' + servers[2].port)
+        expect(follows).to.have.lengthOf(0)
       }
+    })
 
-      {
-        const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted' })
-        expect(body.total).to.equal(1)
-        expect(body.data).to.have.lengthOf(1)
-      }
+    it('Should have the correct follows counts', async function () {
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
+      await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
 
-      {
-        const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
-        expect(body.total).to.equal(0)
-        expect(body.data).to.have.lengthOf(0)
-      }
+      // Server 2 and 3 does not know server 1 follow another server (there was not a refresh)
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
 
-      {
-        const body = await followsCommands[2].getFollowers({
-          start,
-          count,
-          sort,
-          search,
-          state: 'accepted',
-          actorType: 'Application'
-        })
-        expect(body.total).to.equal(1)
-        expect(body.data).to.have.lengthOf(1)
-      }
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
+    })
 
-      {
-        const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'pending' })
-        expect(body.total).to.equal(0)
-        expect(body.data).to.have.lengthOf(0)
-      }
-    }
+    it('Should unfollow server 3 on server 1', async function () {
+      this.timeout(15000)
 
-    {
-      const body = await followsCommands[2].getFollowers({ start, count, sort, search: 'bla' })
-      expect(body.total).to.equal(0)
+      await servers[0].follows.unfollow({ target: servers[2] })
+
+      await waitJobs(servers)
+    })
+
+    it('Should not follow server 3 on server 1 anymore', async function () {
+      const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' })
+      expect(body.total).to.equal(1)
 
       const follows = body.data
-      expect(follows.length).to.equal(0)
-    }
-  })
+      expect(follows).to.be.an('array')
+      expect(follows).to.have.lengthOf(1)
 
-  it('Should have 0 followers on server 1', async function () {
-    const body = await followsCommands[0].getFollowers({ start: 0, count: 5, sort: 'createdAt' })
-    expect(body.total).to.equal(0)
+      expect(follows[0].following.host).to.equal(servers[1].host)
+    })
 
-    const follows = body.data
-    expect(follows).to.be.an('array')
-    expect(follows.length).to.equal(0)
-  })
+    it('Should not have server 1 as follower on server 3 anymore', async function () {
+      const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
+      expect(body.total).to.equal(0)
 
-  it('Should have the correct follows counts', async function () {
-    await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 })
-    await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
-    await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 })
+      const follows = body.data
+      expect(follows).to.be.an('array')
+      expect(follows).to.have.lengthOf(0)
+    })
 
-    // Server 2 and 3 does not know server 1 follow another server (there was not a refresh)
-    await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-    await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
+    it('Should have the correct follows counts after the unfollow', async function () {
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
 
-    await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-    await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 })
-  })
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
 
-  it('Should unfollow server 3 on server 1', async function () {
-    this.timeout(5000)
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 })
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
+    })
 
-    await followsCommands[0].unfollow({ target: servers[2] })
+    it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () {
+      this.timeout(60000)
 
-    await waitJobs(servers)
-  })
+      await servers[1].videos.upload({ attributes: { name: 'server2' } })
+      await servers[2].videos.upload({ attributes: { name: 'server3' } })
 
-  it('Should not follow server 3 on server 1 anymore', async function () {
-    const body = await followsCommands[0].getFollowings({ start: 0, count: 2, sort: 'createdAt' })
-    expect(body.total).to.equal(1)
+      await waitJobs(servers)
 
-    const follows = body.data
-    expect(follows).to.be.an('array')
-    expect(follows.length).to.equal(1)
+      {
+        const { total, data } = await servers[0].videos.list()
+        expect(total).to.equal(1)
+        expect(data[0].name).to.equal('server2')
+      }
 
-    expect(follows[0].following.host).to.equal('localhost:' + servers[1].port)
-  })
+      {
+        const { total, data } = await servers[1].videos.list()
+        expect(total).to.equal(1)
+        expect(data[0].name).to.equal('server2')
+      }
 
-  it('Should not have server 1 as follower on server 3 anymore', async function () {
-    const body = await followsCommands[2].getFollowers({ start: 0, count: 1, sort: 'createdAt' })
-    expect(body.total).to.equal(0)
+      {
+        const { total, data } = await servers[2].videos.list()
+        expect(total).to.equal(1)
+        expect(data[0].name).to.equal('server3')
+      }
+    })
 
-    const follows = body.data
-    expect(follows).to.be.an('array')
-    expect(follows.length).to.equal(0)
-  })
+    it('Should remove account follow', async function () {
+      this.timeout(15000)
 
-  it('Should have the correct follows counts 2', async function () {
-    await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-    await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
+      await servers[0].follows.unfollow({ target: 'root@' + servers[1].host })
 
-    await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-    await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
+      await waitJobs(servers)
+    })
 
-    await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 0 })
-    await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 0, following: 0 })
-  })
+    it('Should have removed the account follow', async function () {
+      await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
+      await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
 
-  it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () {
-    this.timeout(60000)
+      {
+        const { total, data } = await servers[0].follows.getFollowings()
+        expect(total).to.equal(0)
+        expect(data).to.have.lengthOf(0)
+      }
 
-    await servers[1].videos.upload({ attributes: { name: 'server2' } })
-    await servers[2].videos.upload({ attributes: { name: 'server3' } })
+      {
+        const { total, data } = await servers[0].videos.list()
+        expect(total).to.equal(0)
+        expect(data).to.have.lengthOf(0)
+      }
+    })
 
-    await waitJobs(servers)
+    it('Should follow a channel', async function () {
+      this.timeout(15000)
 
-    {
-      const { total, data } = await servers[0].videos.list()
-      expect(total).to.equal(1)
-      expect(data[0].name).to.equal('server2')
-    }
+      await servers[0].follows.follow({
+        handles: [ 'root_channel@' + servers[1].host ]
+      })
 
-    {
-      const { total, data } = await servers[1].videos.list()
-      expect(total).to.equal(1)
-      expect(data[0].name).to.equal('server2')
-    }
+      await waitJobs(servers)
 
-    {
-      const { total, data } = await servers[2].videos.list()
-      expect(total).to.equal(1)
-      expect(data[0].name).to.equal('server3')
-    }
+      await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
+      await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
+
+      {
+        const { total, data } = await servers[0].follows.getFollowings()
+        expect(total).to.equal(1)
+        expect(data).to.have.lengthOf(1)
+      }
+
+      {
+        const { total, data } = await servers[0].videos.list()
+        expect(total).to.equal(1)
+        expect(data).to.have.lengthOf(1)
+      }
+    })
   })
 
-  describe('Should propagate data on a new following', function () {
-    let video4: Video
+  describe('Should propagate data on a new server follow', function () {
+    let video4: VideoCreateResult
 
     before(async function () {
       this.timeout(50000)
@@ -324,83 +385,64 @@ describe('Test follows', function () {
 
       await servers[2].videos.upload({ attributes: { name: 'server3-2' } })
       await servers[2].videos.upload({ attributes: { name: 'server3-3' } })
-      await servers[2].videos.upload({ attributes: video4Attributes })
+      video4 = await servers[2].videos.upload({ attributes: video4Attributes })
       await servers[2].videos.upload({ attributes: { name: 'server3-5' } })
       await servers[2].videos.upload({ attributes: { name: 'server3-6' } })
 
       {
         const userAccessToken = await servers[2].users.generateUserAndToken('captain')
 
-        const { data } = await servers[2].videos.list()
-        video4 = data.find(v => v.name === 'server3-4')
-
-        {
-          await servers[2].videos.rate({ id: video4.id, rating: 'like' })
-          await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' })
-        }
-
-        {
-          {
-            const text = 'my super first comment'
-            const created = await servers[2].comments.createThread({ videoId: video4.id, text })
-            const threadId = created.id
-
-            const text1 = 'my super answer to thread 1'
-            const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 })
-
-            const text2 = 'my super answer to answer of thread 1'
-            await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text2 })
-
-            const text3 = 'my second answer to thread 1'
-            await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text3 })
-          }
+        await servers[2].videos.rate({ id: video4.id, rating: 'like' })
+        await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' })
+      }
 
-          {
-            const text = 'will be deleted'
-            const created = await servers[2].comments.createThread({ videoId: video4.id, text })
-            const threadId = created.id
+      {
+        await servers[2].comments.createThread({ videoId: video4.id, text: 'my super first comment' })
 
-            const text1 = 'answer to deleted'
-            await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 })
+        await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' })
+        await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' })
+        await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' })
+      }
 
-            const text2 = 'will also be deleted'
-            const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text2 })
+      {
+        const { id: threadId } = await servers[2].comments.createThread({ videoId: video4.id, text: 'will be deleted' })
+        await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' })
 
-            const text3 = 'my second answer to deleted'
-            await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text3 })
+        const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' })
 
-            await servers[2].comments.delete({ videoId: video4.id, commentId: threadId })
-            await servers[2].comments.delete({ videoId: video4.id, commentId: childComment.id })
-          }
-        }
+        await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' })
 
-        {
-          await servers[2].captions.createVideoCaption({
-            language: 'ar',
-            videoId: video4.id,
-            fixture: 'subtitle-good2.vtt'
-          })
-        }
+        await servers[2].comments.delete({ videoId: video4.id, commentId: threadId })
+        await servers[2].comments.delete({ videoId: video4.id, commentId: replyId })
       }
 
+      await servers[2].captions.createVideoCaption({
+        language: 'ar',
+        videoId: video4.id,
+        fixture: 'subtitle-good2.vtt'
+      })
+
       await waitJobs(servers)
 
       // Server 1 follows server 3
-      await followsCommands[0].follow({ targets: [ servers[2].url ] })
+      await servers[0].follows.follow({ hosts: [ servers[2].url ] })
 
       await waitJobs(servers)
     })
 
-    it('Should have the correct follows counts 3', async function () {
-      await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 })
-      await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
-      await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 })
+    it('Should have the correct follows counts', async function () {
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
+      await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
+      await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
 
-      await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-      await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
+      await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
+      await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
 
-      await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
-      await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 })
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
+      await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
     })
 
     it('Should have propagated videos', async function () {
@@ -426,7 +468,7 @@ describe('Test follows', function () {
         support: 'my super support text',
         account: {
           name: 'root',
-          host: 'localhost:' + servers[2].port
+          host: servers[2].host
         },
         isLocal,
         commentsEnabled: true,
@@ -467,7 +509,7 @@ describe('Test follows', function () {
         expect(comment.videoId).to.equal(video4.id)
         expect(comment.id).to.equal(comment.threadId)
         expect(comment.account.name).to.equal('root')
-        expect(comment.account.host).to.equal('localhost:' + servers[2].port)
+        expect(comment.account.host).to.equal(servers[2].host)
         expect(comment.totalReplies).to.equal(3)
         expect(dateIsValid(comment.createdAt as string)).to.be.true
         expect(dateIsValid(comment.updatedAt as string)).to.be.true
@@ -541,14 +583,39 @@ describe('Test follows', function () {
     it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
       this.timeout(5000)
 
-      await followsCommands[0].unfollow({ target: servers[2] })
+      await servers[0].follows.unfollow({ target: servers[2] })
 
       await waitJobs(servers)
 
       const { total } = await servers[0].videos.list()
       expect(total).to.equal(1)
     })
+  })
+
+  describe('Should propagate data on a new channel follow', function () {
+
+    before(async function () {
+      this.timeout(60000)
 
+      await servers[2].videos.upload({ attributes: { name: 'server3-7' } })
+
+      await waitJobs(servers)
+
+      const video = await servers[0].videos.find({ name: 'server3-7' })
+      expect(video).to.not.exist
+    })
+
+    it('Should have propagated channel video', async function () {
+      this.timeout(60000)
+
+      await servers[0].follows.follow({ handles: [ 'root_channel@' + servers[2].host ] })
+
+      await waitJobs(servers)
+
+      const video = await servers[0].videos.find({ name: 'server3-7' })
+
+      expect(video).to.exist
+    })
   })
 
   after(async function () {
index 1f751c957025aa6cfc228b2805a3a2fa6b5dab23..2f39503540b646e80690c4282c93baaeace08dfd 100644 (file)
@@ -97,8 +97,8 @@ describe('Test handle downs', function () {
     this.timeout(240000)
 
     // Server 2 and 3 follow server 1
-    await servers[1].follows.follow({ targets: [ servers[0].url ] })
-    await servers[2].follows.follow({ targets: [ servers[0].url ] })
+    await servers[1].follows.follow({ hosts: [ servers[0].url ] })
+    await servers[2].follows.follow({ hosts: [ servers[0].url ] })
 
     await waitJobs(servers)
 
@@ -180,7 +180,7 @@ describe('Test handle downs', function () {
     await servers[1].follows.unfollow({ target: servers[0] })
     await waitJobs(servers)
 
-    await servers[1].follows.follow({ targets: [ servers[0].url ] })
+    await servers[1].follows.follow({ hosts: [ servers[0].url ] })
 
     await waitJobs(servers)
 
index 942602b70c80df9d7e28375b88c0e467fdaccb69..5ec771429b41737e115b1701bf7cf293975b4dee 100644 (file)
@@ -43,7 +43,7 @@ describe('Test stats (excluding redundancy)', function () {
     // Wait the video views repeatable job
     await wait(8000)
 
-    await servers[2].follows.follow({ targets: [ servers[0].url ] })
+    await servers[2].follows.follow({ hosts: [ servers[0].url ] })
     await waitJobs(servers)
   })
 
index 565b4bd779885190759fdeffdcdddd453f8ea89e..77b99886d24208a976d63f782ae25c2edd564ea6 100644 (file)
@@ -224,7 +224,7 @@ describe('Test users subscriptions', function () {
   it('Should have server 1 follow server 3 and display server 3 videos', async function () {
     this.timeout(60000)
 
-    await servers[0].follows.follow({ targets: [ servers[2].url ] })
+    await servers[0].follows.follow({ hosts: [ servers[2].url ] })
 
     await waitJobs(servers)
 
index 066da88eefc9842af70ac892ac5489dd7b3d1b77..1419ae820493e479124c09784cf7bf3340eaf9af 100644 (file)
@@ -103,7 +103,7 @@ describe('Test users', function () {
       token = 'my_super_token'
 
       await server.follows.follow({
-        targets: [ 'http://example.com' ],
+        hosts: [ 'http://example.com' ],
         token,
         expectedStatus: HttpStatusCode.UNAUTHORIZED_401
       })
index dce674ac5cf7284134084c10f53c88f5d39501df..2b889cf66481fde76852e59599b853386d8532f9 100644 (file)
@@ -1,5 +1,5 @@
 import { pick } from 'lodash'
-import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList } from '@shared/models'
+import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models'
 import { AbstractCommand, OverrideCommandOptions } from '../shared'
 import { PeerTubeServer } from './server'
 
@@ -29,13 +29,13 @@ export class FollowsCommand extends AbstractCommand {
   }
 
   getFollowings (options: OverrideCommandOptions & {
-    start: number
-    count: number
-    sort: string
+    start?: number
+    count?: number
+    sort?: string
     search?: string
     actorType?: ActivityPubActorType
     state?: FollowState
-  }) {
+  } = {}) {
     const path = '/api/v1/server/following'
 
     const toPick = [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]
@@ -52,26 +52,41 @@ export class FollowsCommand extends AbstractCommand {
   }
 
   follow (options: OverrideCommandOptions & {
-    targets: string[]
+    hosts?: string[]
+    handles?: string[]
   }) {
     const path = '/api/v1/server/following'
 
-    const hosts = options.targets.map(f => f.replace(/^http:\/\//, ''))
+    const fields: ServerFollowCreate = {}
+
+    if (options.hosts) {
+      fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, ''))
+    }
+
+    if (options.handles) {
+      fields.handles = options.handles
+    }
 
     return this.postBodyRequest({
       ...options,
 
       path,
-      fields: { hosts },
+      fields,
       implicitToken: true,
       defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
     })
   }
 
   async unfollow (options: OverrideCommandOptions & {
-    target: PeerTubeServer
+    target: PeerTubeServer | string
   }) {
-    const path = '/api/v1/server/following/' + options.target.host
+    const { target } = options
+
+    const handle = typeof target === 'string'
+      ? target
+      : target.host
+
+    const path = '/api/v1/server/following/' + handle
 
     return this.deleteRequest({
       ...options,
index 0188be1aaf2453ba395852e187656ed6c741e91a..698238f29e89dff43f8d76c124011eace1136a56 100644 (file)
@@ -3,8 +3,8 @@ import { PeerTubeServer } from './server'
 
 async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
   await Promise.all([
-    server1.follows.follow({ targets: [ server2.url ] }),
-    server2.follows.follow({ targets: [ server1.url ] })
+    server1.follows.follow({ hosts: [ server2.url ] }),
+    server2.follows.follow({ hosts: [ server1.url ] })
   ])
 
   // Wait request propagation
similarity index 50%
rename from shared/extra-utils/users/accounts.ts
rename to shared/extra-utils/users/actors.ts
index 9fc1bcfc4f826cdb7ef5c8a5b4f994fb6ea2a173..cfcc7d0a73c7d0cc64f5ed71ce9aa0f2ce69a47e 100644 (file)
@@ -4,22 +4,31 @@ import { expect } from 'chai'
 import { pathExists, readdir } from 'fs-extra'
 import { join } from 'path'
 import { root } from '@server/helpers/core-utils'
+import { Account, VideoChannel } from '@shared/models'
 import { PeerTubeServer } from '../server'
 
-async function expectAccountFollows (options: {
+async function expectChannelsFollows (options: {
   server: PeerTubeServer
   handle: string
   followers: number
   following: number
 }) {
-  const { server, handle, followers, following } = options
+  const { server } = options
+  const { data } = await server.channels.list()
 
-  const body = await server.accounts.list()
-  const account = body.data.find(a => a.name + '@' + a.host === handle)
+  return expectActorFollow({ ...options, data })
+}
 
-  const message = `${handle} on ${server.url}`
-  expect(account.followersCount).to.equal(followers, message)
-  expect(account.followingCount).to.equal(following, message)
+async function expectAccountFollows (options: {
+  server: PeerTubeServer
+  handle: string
+  followers: number
+  following: number
+}) {
+  const { server } = options
+  const { data } = await server.accounts.list()
+
+  return expectActorFollow({ ...options, data })
 }
 
 async function checkActorFilesWereRemoved (filename: string, serverNumber: number) {
@@ -40,5 +49,25 @@ async function checkActorFilesWereRemoved (filename: string, serverNumber: numbe
 
 export {
   expectAccountFollows,
+  expectChannelsFollows,
   checkActorFilesWereRemoved
 }
+
+// ---------------------------------------------------------------------------
+
+function expectActorFollow (options: {
+  server: PeerTubeServer
+  data: (Account | VideoChannel)[]
+  handle: string
+  followers: number
+  following: number
+}) {
+  const { server, data, handle, followers, following } = options
+
+  const actor = data.find(a => a.name + '@' + a.host === handle)
+  const message = `${handle} on ${server.url}`
+
+  expect(actor, message).to.exist
+  expect(actor.followersCount).to.equal(followers, message)
+  expect(actor.followingCount).to.equal(following, message)
+}
index fbb454e8f017b04a461b1acd99161b43eb5a8c56..460a06f7026c3909cdd9df8b2fe6b87c08b7ffe2 100644 (file)
@@ -1,5 +1,5 @@
 export * from './accounts-command'
-export * from './accounts'
+export * from './actors'
 export * from './blocklist-command'
 export * from './login'
 export * from './login-command'
index dd14e4b641ea4d893d504d54b3332fe82d164264..5034c57ad5190ca796be87c85b1cbcbd4cdca217 100644 (file)
@@ -5,6 +5,10 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared'
 
 export class CommentsCommand extends AbstractCommand {
 
+  private lastVideoId: number | string
+  private lastThreadId: number
+  private lastReplyId: number
+
   listForAdmin (options: OverrideCommandOptions & {
     start?: number
     count?: number
@@ -80,6 +84,9 @@ export class CommentsCommand extends AbstractCommand {
       defaultExpectedStatus: HttpStatusCode.OK_200
     }))
 
+    this.lastThreadId = body.comment.id
+    this.lastVideoId = videoId
+
     return body.comment
   }
 
@@ -100,9 +107,23 @@ export class CommentsCommand extends AbstractCommand {
       defaultExpectedStatus: HttpStatusCode.OK_200
     }))
 
+    this.lastReplyId = body.comment.id
+
     return body.comment
   }
 
+  async addReplyToLastReply (options: OverrideCommandOptions & {
+    text: string
+  }) {
+    return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId })
+  }
+
+  async addReplyToLastThread (options: OverrideCommandOptions & {
+    text: string
+  }) {
+    return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId })
+  }
+
   async findCommentId (options: OverrideCommandOptions & {
     videoId: number | string
     text: string
index 40cc4dc28da3dc08995beaa4d79494b7da35d01e..98465e8f69f9d7741709ab16c5c7f04938ff2bdb 100644 (file)
@@ -267,6 +267,16 @@ export class VideosCommand extends AbstractCommand {
 
   // ---------------------------------------------------------------------------
 
+  async find (options: OverrideCommandOptions & {
+    name: string
+  }) {
+    const { data } = await this.list(options)
+
+    return data.find(v => v.name === options.name)
+  }
+
+  // ---------------------------------------------------------------------------
+
   update (options: OverrideCommandOptions & {
     id: number | string
     attributes?: VideoEdit
index 06bf5c5990c635815c74dc0483ce54320a598a24..0f7646c7afcfbb0899fc34a7b7149754e6cfd01a 100644 (file)
@@ -10,4 +10,5 @@ export * from './peertube-problem-document.model'
 export * from './server-config.model'
 export * from './server-debug.model'
 export * from './server-error-code.enum'
+export * from './server-follow-create.model'
 export * from './server-stats.model'
diff --git a/shared/models/server/server-follow-create.model.ts b/shared/models/server/server-follow-create.model.ts
new file mode 100644 (file)
index 0000000..3f90c7d
--- /dev/null
@@ -0,0 +1,4 @@
+export interface ServerFollowCreate {
+  hosts?: string[]
+  handles?: string[]
+}
index 99a725ead0b37f3bc62e436108be060d237323a5..76e78fe53a65fc12f4b51a754a18c76ed0de97d4 100644 (file)
@@ -716,7 +716,7 @@ paths:
           - admin
       tags:
         - Instance Follows
-      summary: Follow a list of servers
+      summary: Follow a list of actors (PeerTube instance, channel or account)
       responses:
         '204':
           description: successful operation
@@ -734,28 +734,32 @@ paths:
                     type: string
                     format: hostname
                   uniqueItems: true
+                handles:
+                  type: array
+                  items:
+                    type: string
+                  uniqueItems: true
 
-  '/server/following/{host}':
+  '/server/following/{hostOrHandle}':
     delete:
-      summary: Unfollow a server
+      summary: Unfollow an actor (PeerTube instance, channel or account)
       security:
         - OAuth2:
           - admin
       tags:
         - Instance Follows
       parameters:
-        - name: host
+        - name: hostOrHandle
           in: path
           required: true
-          description: The host to unfollow
+          description: The hostOrHandle to unfollow
           schema:
             type: string
-            format: hostname
       responses:
         '204':
           description: successful operation
         '404':
-          description: host not found
+          description: host or handle not found
 
   /users:
     post: