aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-07-20 14:15:15 +0200
committerChocobozzz <me@florianbigard.com>2021-07-21 13:35:31 +0200
commit4d029ef8ec3d5274eeaa3ee6d808eb7035e7faef (patch)
tree20bcdd660ab4eb731814db3a4a40fffb48ce7482
parent7f28f2ddbaeecf451d501e99ded0408c14a33600 (diff)
downloadPeerTube-4d029ef8ec3d5274eeaa3ee6d808eb7035e7faef.tar.gz
PeerTube-4d029ef8ec3d5274eeaa3ee6d808eb7035e7faef.tar.zst
PeerTube-4d029ef8ec3d5274eeaa3ee6d808eb7035e7faef.zip
Add ability for instances to follow any actor
-rw-r--r--client/src/app/+admin/admin.component.ts4
-rw-r--r--client/src/app/+admin/admin.module.ts3
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html4
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.html42
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.scss3
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.ts69
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html21
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts22
-rw-r--r--client/src/app/+admin/follows/following-list/index.ts1
-rw-r--r--client/src/app/+admin/follows/follows.routes.ts4
-rw-r--r--client/src/app/shared/form-validators/batch-domains-validators.ts60
-rw-r--r--client/src/app/shared/form-validators/host-validators.ts105
-rw-r--r--client/src/app/shared/form-validators/host.ts8
-rw-r--r--client/src/app/shared/form-validators/index.ts1
-rw-r--r--client/src/app/shared/shared-instance/instance-follow.service.ts13
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.html14
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.ts8
-rw-r--r--server/controllers/api/server/follows.ts21
-rw-r--r--server/helpers/custom-validators/follows.ts20
-rw-r--r--server/helpers/custom-validators/servers.ts1
-rw-r--r--server/lib/activitypub/crawl.ts3
-rw-r--r--server/lib/activitypub/follow.ts17
-rw-r--r--server/middlewares/validators/follows.ts37
-rw-r--r--server/models/actor/actor-follow.ts26
-rw-r--r--server/models/video/sql/videos-id-list-query-builder.ts6
-rw-r--r--server/tests/api/check-params/follows.ts36
-rw-r--r--server/tests/api/moderation/blocklist.ts4
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts4
-rw-r--r--server/tests/api/redundancy/redundancy-constraints.ts4
-rw-r--r--server/tests/api/server/auto-follows.ts2
-rw-r--r--server/tests/api/server/follows-moderation.ts10
-rw-r--r--server/tests/api/server/follows.ts595
-rw-r--r--server/tests/api/server/handle-down.ts6
-rw-r--r--server/tests/api/server/stats.ts2
-rw-r--r--server/tests/api/users/user-subscriptions.ts2
-rw-r--r--server/tests/api/users/users.ts2
-rw-r--r--shared/extra-utils/server/follows-command.ts35
-rw-r--r--shared/extra-utils/server/follows.ts4
-rw-r--r--shared/extra-utils/users/actors.ts (renamed from shared/extra-utils/users/accounts.ts)43
-rw-r--r--shared/extra-utils/users/index.ts2
-rw-r--r--shared/extra-utils/videos/comments-command.ts21
-rw-r--r--shared/extra-utils/videos/videos-command.ts10
-rw-r--r--shared/models/server/index.ts1
-rw-r--r--shared/models/server/server-follow-create.model.ts4
-rw-r--r--support/doc/api/openapi.yaml18
45 files changed, 837 insertions, 481 deletions
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index dd92ed2ca..4b6fab6ed 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -26,12 +26,12 @@ export class AdminComponent implements OnInit {
26 label: $localize`Federation`, 26 label: $localize`Federation`,
27 children: [ 27 children: [
28 { 28 {
29 label: $localize`Instances you follow`, 29 label: $localize`Following`,
30 routerLink: '/admin/follows/following-list', 30 routerLink: '/admin/follows/following-list',
31 iconName: 'following' 31 iconName: 'following'
32 }, 32 },
33 { 33 {
34 label: $localize`Instances following you`, 34 label: $localize`Followers`,
35 routerLink: '/admin/follows/followers-list', 35 routerLink: '/admin/follows/followers-list',
36 iconName: 'follower' 36 iconName: 'follower'
37 }, 37 },
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index a7fe20b07..1ea7b9784 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -25,7 +25,7 @@ import {
25 EditVODTranscodingComponent 25 EditVODTranscodingComponent
26} from './config' 26} from './config'
27import { ConfigService } from './config/shared/config.service' 27import { ConfigService } from './config/shared/config.service'
28import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows' 28import { FollowersListComponent, FollowModalComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
29import { FollowingListComponent } from './follows/following-list/following-list.component' 29import { FollowingListComponent } from './follows/following-list/following-list.component'
30import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' 30import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
31import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 31import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
@@ -68,6 +68,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
68 FollowsComponent, 68 FollowsComponent,
69 FollowersListComponent, 69 FollowersListComponent,
70 FollowingListComponent, 70 FollowingListComponent,
71 FollowModalComponent,
71 RedundancyCheckboxComponent, 72 RedundancyCheckboxComponent,
72 VideoRedundanciesListComponent, 73 VideoRedundanciesListComponent,
73 VideoRedundancyInformationComponent, 74 VideoRedundancyInformationComponent,
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index c2e9a4df6..08459634d 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -1,6 +1,6 @@
1<h1> 1<h1>
2 <my-global-icon iconName="follower" aria-hidden="true"></my-global-icon> 2 <my-global-icon iconName="follower" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Instances following you</ng-container> 3 <ng-container i18n>Followers of your instance</ng-container>
4</h1> 4</h1>
5 5
6<p-table 6<p-table
@@ -21,7 +21,7 @@
21 <ng-template pTemplate="header"> 21 <ng-template pTemplate="header">
22 <tr> 22 <tr>
23 <th style="width: 150px;" i18n>Actions</th> 23 <th style="width: 150px;" i18n>Actions</th>
24 <th i18n>Follower handle</th> 24 <th i18n>Follower</th>
25 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> 25 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
26 <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> 26 <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
27 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 27 <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
index 000000000..d0761b718
--- /dev/null
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.html
@@ -0,0 +1,42 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Follow</h4>
4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div>
7
8 <div class="modal-body">
9 <form novalidate [formGroup]="form" (ngSubmit)="submit()">
10 <div class="form-group">
11 <label i18n for="hostsOrHandles">1 host (without "http://"), account handle or channel handle per line</label>
12
13 <textarea
14 [placeholder]="placeholder" formControlName="hostsOrHandles" type="text" id="hostsOrHandles" name="hostsOrHandles"
15 class="form-control" [ngClass]="{ 'input-error': formErrors['hostsOrHandles'] }" ngbAutofocus
16 ></textarea>
17
18 <div *ngIf="formErrors.hostsOrHandles" class="form-error">
19 {{ formErrors.hostsOrHandles }}
20
21 <div *ngIf="form.controls['hostsOrHandles'].errors.validHostsOrHandles">
22 {{ form.controls['hostsOrHandles'].errors.validHostsOrHandles.value }}
23 </div>
24 </div>
25 </div>
26
27 <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
28 It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
29 </div>
30
31 <div class="form-group inputs">
32 <input
33 type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
34 (click)="hide()" (key.enter)="hide()"
35 >
36
37 <input type="submit" i18n-value value="Follow" class="peertube-button orange-button" [disabled]="!form.valid" />
38 </div>
39 </form>
40 </div>
41
42</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
index 000000000..9621a566f
--- /dev/null
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.scss
@@ -0,0 +1,3 @@
1textarea {
2 height: 200px;
3}
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
index 000000000..dc6909200
--- /dev/null
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
@@ -0,0 +1,69 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { InstanceFollowService } from '@app/shared/shared-instance'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8
9@Component({
10 selector: 'my-follow-modal',
11 templateUrl: './follow-modal.component.html',
12 styleUrls: [ './follow-modal.component.scss' ]
13})
14export class FollowModalComponent extends FormReactive implements OnInit {
15 @ViewChild('modal', { static: true }) modal: NgbModal
16
17 @Output() newFollow = new EventEmitter<void>()
18
19 placeholder = 'example.com\nchocobozzz@example.com\nchocobozzz_channel@example.com'
20
21 private openedModal: NgbModalRef
22
23 constructor (
24 protected formValidatorService: FormValidatorService,
25 private modalService: NgbModal,
26 private followService: InstanceFollowService,
27 private notifier: Notifier
28 ) {
29 super()
30 }
31
32 ngOnInit () {
33 this.buildForm({
34 hostsOrHandles: UNIQUE_HOSTS_OR_HANDLE_VALIDATOR
35 })
36 }
37
38 openModal () {
39 this.openedModal = this.modalService.open(this.modal, { centered: true })
40 }
41
42 hide () {
43 this.openedModal.close()
44 }
45
46 submit () {
47 this.addFollowing()
48
49 this.form.reset()
50 this.hide()
51 }
52
53 httpEnabled () {
54 return window.location.protocol === 'https:'
55 }
56
57 private async addFollowing () {
58 const hostsOrHandles = splitAndGetNotEmpty(this.form.value['hostsOrHandles'])
59
60 this.followService.follow(hostsOrHandles).subscribe(
61 () => {
62 this.notifier.success($localize`Follow request(s) sent!`)
63 this.newFollow.emit()
64 },
65
66 err => this.notifier.error(err.message)
67 )
68 }
69}
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index e7c0c9088..75b0efca8 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -1,6 +1,6 @@
1<h1> 1<h1>
2 <my-global-icon iconName="following" aria-hidden="true"></my-global-icon> 2 <my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Instances you follow</ng-container> 3 <ng-container i18n>Your instance subscriptions</ng-container>
4</h1> 4</h1>
5 5
6<p-table 6<p-table
@@ -13,9 +13,9 @@
13 <ng-template pTemplate="caption"> 13 <ng-template pTemplate="caption">
14 <div class="caption"> 14 <div class="caption">
15 <div class="left-buttons"> 15 <div class="left-buttons">
16 <a class="follow-button" (click)="addDomainsToFollow()" (key.enter)="addDomainsToFollow()"> 16 <a class="follow-button" (click)="openFollowModal()" (key.enter)="openFollowModal()">
17 <my-global-icon iconName="following" aria-hidden="true"></my-global-icon> 17 <my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
18 <ng-container i18n>Follow instances</ng-container> 18 <ng-container i18n>Follow</ng-container>
19 </a> 19 </a>
20 </div> 20 </div>
21 21
@@ -28,7 +28,7 @@
28 <ng-template pTemplate="header"> 28 <ng-template pTemplate="header">
29 <tr> 29 <tr>
30 <th style="width: 150px;" i18n>Action</th> 30 <th style="width: 150px;" i18n>Action</th>
31 <th i18n>Host</th> 31 <th i18n>Following</th>
32 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> 32 <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
33 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> 33 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
34 <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> 34 <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
@@ -41,8 +41,8 @@
41 <my-delete-button label="Unfollow" i18n-label (click)="removeFollowing(follow)"></my-delete-button> 41 <my-delete-button label="Unfollow" i18n-label (click)="removeFollowing(follow)"></my-delete-button>
42 </td> 42 </td>
43 <td> 43 <td>
44 <a [href]="'https://' + follow.following.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer"> 44 <a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
45 {{ follow.following.host }} 45 {{ follow.following.name + '@' + follow.following.host }}
46 <span class="glyphicon glyphicon-new-window"></span> 46 <span class="glyphicon glyphicon-new-window"></span>
47 </a> 47 </a>
48 </td> 48 </td>
@@ -57,6 +57,7 @@
57 <td>{{ follow.createdAt | date: 'short' }}</td> 57 <td>{{ follow.createdAt | date: 'short' }}</td>
58 <td> 58 <td>
59 <my-redundancy-checkbox 59 <my-redundancy-checkbox
60 *ngIf="isInstanceFollowing(follow)"
60 [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed" 61 [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
61 ></my-redundancy-checkbox> 62 ></my-redundancy-checkbox>
62 </td> 63 </td>
@@ -75,10 +76,4 @@
75 </ng-template> 76 </ng-template>
76</p-table> 77</p-table>
77 78
78<my-batch-domains-modal #batchDomainsModal i18n-action action="Follow domains" (domains)="addFollowing($event)"> 79<my-follow-modal #followModal></my-follow-modal>
79 <ng-container ngProjectAs="warning">
80 <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
81 It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
82 </div>
83 </ng-container>
84</my-batch-domains-modal>
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index b63fe08c0..ba62dfa23 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -4,13 +4,14 @@ import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
4import { InstanceFollowService } from '@app/shared/shared-instance' 4import { InstanceFollowService } from '@app/shared/shared-instance'
5import { BatchDomainsModalComponent } from '@app/shared/shared-moderation' 5import { BatchDomainsModalComponent } from '@app/shared/shared-moderation'
6import { ActorFollow } from '@shared/models' 6import { ActorFollow } from '@shared/models'
7import { FollowModalComponent } from './follow-modal.component'
7 8
8@Component({ 9@Component({
9 templateUrl: './following-list.component.html', 10 templateUrl: './following-list.component.html',
10 styleUrls: [ '../follows.component.scss', './following-list.component.scss' ] 11 styleUrls: [ '../follows.component.scss', './following-list.component.scss' ]
11}) 12})
12export class FollowingListComponent extends RestTable implements OnInit { 13export class FollowingListComponent extends RestTable implements OnInit {
13 @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent 14 @ViewChild('followModal') followModal: FollowModalComponent
14 15
15 following: ActorFollow[] = [] 16 following: ActorFollow[] = []
16 totalRecords = 0 17 totalRecords = 0
@@ -33,23 +34,12 @@ export class FollowingListComponent extends RestTable implements OnInit {
33 return 'FollowingListComponent' 34 return 'FollowingListComponent'
34 } 35 }
35 36
36 addDomainsToFollow () { 37 openFollowModal () {
37 this.batchDomainsModal.openModal() 38 this.followModal.openModal()
38 } 39 }
39 40
40 httpEnabled () { 41 isInstanceFollowing (follow: ActorFollow) {
41 return window.location.protocol === 'https:' 42 return follow.following.name === 'peertube'
42 }
43
44 async addFollowing (hosts: string[]) {
45 this.followService.follow(hosts).subscribe(
46 () => {
47 this.notifier.success($localize`Follow request(s) sent!`)
48 this.reloadData()
49 },
50
51 err => this.notifier.error(err.message)
52 )
53 } 43 }
54 44
55 async removeFollowing (follow: ActorFollow) { 45 async removeFollowing (follow: ActorFollow) {
diff --git a/client/src/app/+admin/follows/following-list/index.ts b/client/src/app/+admin/follows/following-list/index.ts
index a70d46a7e..88be0ed4c 100644
--- a/client/src/app/+admin/follows/following-list/index.ts
+++ b/client/src/app/+admin/follows/following-list/index.ts
@@ -1 +1,2 @@
1export * from './follow-modal.component'
1export * from './following-list.component' 2export * from './following-list.component'
diff --git a/client/src/app/+admin/follows/follows.routes.ts b/client/src/app/+admin/follows/follows.routes.ts
index cd70daf77..3843b42b5 100644
--- a/client/src/app/+admin/follows/follows.routes.ts
+++ b/client/src/app/+admin/follows/follows.routes.ts
@@ -25,7 +25,7 @@ export const FollowsRoutes: Routes = [
25 component: FollowingListComponent, 25 component: FollowingListComponent,
26 data: { 26 data: {
27 meta: { 27 meta: {
28 title: $localize`Following list` 28 title: $localize`Following`
29 } 29 }
30 } 30 }
31 }, 31 },
@@ -34,7 +34,7 @@ export const FollowsRoutes: Routes = [
34 component: FollowersListComponent, 34 component: FollowersListComponent,
35 data: { 35 data: {
36 meta: { 36 meta: {
37 title: $localize`Followers list` 37 title: $localize`Followers`
38 } 38 }
39 } 39 }
40 }, 40 },
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
index 423d1337f..000000000
--- a/client/src/app/shared/form-validators/batch-domains-validators.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import { AbstractControl, FormControl, ValidatorFn, Validators } from '@angular/forms'
2import { BuildFormValidator } from './form-validator.model'
3import { validateHost } from './host'
4
5export function getNotEmptyHosts (hosts: string) {
6 return hosts
7 .split('\n')
8 .filter((host: string) => host && host.length !== 0) // Eject empty hosts
9}
10
11const validDomains: ValidatorFn = (control: FormControl) => {
12 if (!control.value) return null
13
14 const newHostsErrors = []
15 const hosts = getNotEmptyHosts(control.value)
16
17 for (const host of hosts) {
18 if (validateHost(host) === false) {
19 newHostsErrors.push($localize`${host} is not valid`)
20 }
21 }
22
23 /* Is not valid. */
24 if (newHostsErrors.length !== 0) {
25 return {
26 'validDomains': {
27 reason: 'invalid',
28 value: newHostsErrors.join('. ') + '.'
29 }
30 }
31 }
32
33 /* Is valid. */
34 return null
35}
36
37const isHostsUnique: ValidatorFn = (control: AbstractControl) => {
38 if (!control.value) return null
39
40 const hosts = getNotEmptyHosts(control.value)
41
42 if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
43 return null
44 } else {
45 return {
46 'uniqueDomains': {
47 reason: 'invalid'
48 }
49 }
50 }
51}
52
53export const DOMAINS_VALIDATOR: BuildFormValidator = {
54 VALIDATORS: [Validators.required, validDomains, isHostsUnique],
55 MESSAGES: {
56 'required': $localize`Domain is required.`,
57 'validDomains': $localize`Domains entered are invalid.`,
58 'uniqueDomains': $localize`Domains entered contain duplicates.`
59 }
60}
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
index 000000000..d750113ef
--- /dev/null
+++ b/client/src/app/shared/form-validators/host-validators.ts
@@ -0,0 +1,105 @@
1import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
2import { BuildFormValidator } from './form-validator.model'
3
4function validateHost (value: string) {
5 // Thanks to http://stackoverflow.com/a/106223
6 const HOST_REGEXP = new RegExp(
7 '^(([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])$'
8 )
9
10 return HOST_REGEXP.test(value)
11}
12
13function validateHandle (value: string) {
14 if (!value) return false
15
16 return value.includes('@')
17}
18
19const validHosts: ValidatorFn = (control: AbstractControl) => {
20 if (!control.value) return null
21
22 const errors = []
23 const hosts = splitAndGetNotEmpty(control.value)
24
25 for (const host of hosts) {
26 if (validateHost(host) === false) {
27 errors.push($localize`${host} is not valid`)
28 }
29 }
30
31 // valid
32 if (errors.length === 0) return null
33
34 return {
35 'validHosts': {
36 reason: 'invalid',
37 value: errors.join('. ') + '.'
38 }
39 }
40}
41
42const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => {
43 if (!control.value) return null
44
45 const errors = []
46 const lines = splitAndGetNotEmpty(control.value)
47
48 for (const line of lines) {
49 if (validateHost(line) === false && validateHandle(line) === false) {
50 errors.push($localize`${line} is not valid`)
51 }
52 }
53
54 // valid
55 if (errors.length === 0) return null
56
57 return {
58 'validHostsOrHandles': {
59 reason: 'invalid',
60 value: errors.join('. ') + '.'
61 }
62 }
63}
64
65// ---------------------------------------------------------------------------
66
67export function splitAndGetNotEmpty (value: string) {
68 return value
69 .split('\n')
70 .filter(line => line && line.length !== 0) // Eject empty hosts
71}
72
73export const unique: ValidatorFn = (control: AbstractControl) => {
74 if (!control.value) return null
75
76 const hosts = splitAndGetNotEmpty(control.value)
77
78 if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
79 return null
80 }
81
82 return {
83 'unique': {
84 reason: 'invalid'
85 }
86 }
87}
88
89export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = {
90 VALIDATORS: [ Validators.required, validHosts, unique ],
91 MESSAGES: {
92 'required': $localize`Domain is required.`,
93 'validHosts': $localize`Hosts entered are invalid.`,
94 'unique': $localize`Hosts entered contain duplicates.`
95 }
96}
97
98export const UNIQUE_HOSTS_OR_HANDLE_VALIDATOR: BuildFormValidator = {
99 VALIDATORS: [ Validators.required, validHostsOrHandles, unique ],
100 MESSAGES: {
101 'required': $localize`Domain is required.`,
102 'validHostsOrHandles': $localize`Hosts or handles are invalid.`,
103 'unique': $localize`Hosts or handles contain duplicates.`
104 }
105}
diff --git a/client/src/app/shared/form-validators/host.ts b/client/src/app/shared/form-validators/host.ts
deleted file mode 100644
index c18a35f9b..000000000
--- a/client/src/app/shared/form-validators/host.ts
+++ /dev/null
@@ -1,8 +0,0 @@
1export function validateHost (value: string) {
2 // Thanks to http://stackoverflow.com/a/106223
3 const HOST_REGEXP = new RegExp(
4 '^(([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])$'
5 )
6
7 return HOST_REGEXP.test(value)
8}
diff --git a/client/src/app/shared/form-validators/index.ts b/client/src/app/shared/form-validators/index.ts
index f621f03a4..c14272a2a 100644
--- a/client/src/app/shared/form-validators/index.ts
+++ b/client/src/app/shared/form-validators/index.ts
@@ -1,5 +1,4 @@
1export * from './form-validator.model' 1export * from './form-validator.model'
2export * from './host'
3 2
4// Don't re export const variables because webpack 4 cannot do tree shaking with them 3// Don't re export const variables because webpack 4 cannot do tree shaking with them
5// export * from './abuse-validators' 4// export * from './abuse-validators'
diff --git a/client/src/app/shared/shared-instance/instance-follow.service.ts b/client/src/app/shared/shared-instance/instance-follow.service.ts
index e52660140..af44020cf 100644
--- a/client/src/app/shared/shared-instance/instance-follow.service.ts
+++ b/client/src/app/shared/shared-instance/instance-follow.service.ts
@@ -4,7 +4,7 @@ import { catchError, map } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http' 4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core' 6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/models' 7import { ActivityPubActorType, ActorFollow, FollowState, ResultList, ServerFollowCreate } from '@shared/models'
8import { environment } from '../../../environments/environment' 8import { environment } from '../../../environments/environment'
9 9
10@Injectable() 10@Injectable()
@@ -64,9 +64,10 @@ export class InstanceFollowService {
64 ) 64 )
65 } 65 }
66 66
67 follow (notEmptyHosts: string[]) { 67 follow (hostsOrHandles: string[]) {
68 const body = { 68 const body: ServerFollowCreate = {
69 hosts: notEmptyHosts 69 handles: hostsOrHandles.filter(v => v.includes('@')),
70 hosts: hostsOrHandles.filter(v => !v.includes('@'))
70 } 71 }
71 72
72 return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body) 73 return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body)
@@ -77,7 +78,9 @@ export class InstanceFollowService {
77 } 78 }
78 79
79 unfollow (follow: ActorFollow) { 80 unfollow (follow: ActorFollow) {
80 return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host) 81 const handle = follow.following.name + '@' + follow.following.host
82
83 return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + handle)
81 .pipe( 84 .pipe(
82 map(this.restExtractor.extractDataBool), 85 map(this.restExtractor.extractDataBool),
83 catchError(res => this.restExtractor.handleError(res)) 86 catchError(res => this.restExtractor.handleError(res))
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.html b/client/src/app/shared/shared-moderation/batch-domains-modal.component.html
index 6a3c65721..8306a96bc 100644
--- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.html
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.html
@@ -1,6 +1,6 @@
1<ng-template #modal> 1<ng-template #modal>
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">{{ action }}</h4> 3 <h4 class="modal-title">{{ action }}</h4>
4 4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> 5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
6 </div> 6 </div>
@@ -11,15 +11,15 @@
11 <label i18n for="hosts">1 host (without "http://") per line</label> 11 <label i18n for="hosts">1 host (without "http://") per line</label>
12 12
13 <textarea 13 <textarea
14 [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts" 14 [placeholder]="placeholder" formControlName="hosts" type="text" id="hosts" name="hosts"
15 class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus 15 class="form-control" [ngClass]="{ 'input-error': formErrors['hosts'] }" ngbAutofocus
16 ></textarea> 16 ></textarea>
17 17
18 <div *ngIf="formErrors.domains" class="form-error"> 18 <div *ngIf="formErrors.hosts" class="form-error">
19 {{ formErrors.domains }} 19 {{ formErrors.hosts }}
20 20
21 <div *ngIf="form.controls['domains'].errors.validDomains"> 21 <div *ngIf="form.controls['hosts'].errors.validHosts">
22 {{ form.controls['domains'].errors.validDomains.value }} 22 {{ form.controls['hosts'].errors.validHosts.value }}
23 </div> 23 </div>
24 </div> 24 </div>
25 </div> 25 </div>
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
index 6edbb6023..20be728f6 100644
--- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
@@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angu
2import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 2import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
5import { DOMAINS_VALIDATOR, getNotEmptyHosts } from '../form-validators/batch-domains-validators' 5import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
6 6
7@Component({ 7@Component({
8 selector: 'my-batch-domains-modal', 8 selector: 'my-batch-domains-modal',
@@ -28,7 +28,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
28 if (!this.action) this.action = $localize`Process domains` 28 if (!this.action) this.action = $localize`Process domains`
29 29
30 this.buildForm({ 30 this.buildForm({
31 domains: DOMAINS_VALIDATOR 31 hosts: UNIQUE_HOSTS_VALIDATOR
32 }) 32 })
33 } 33 }
34 34
@@ -41,9 +41,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
41 } 41 }
42 42
43 submit () { 43 submit () {
44 this.domains.emit( 44 this.domains.emit(splitAndGetNotEmpty(this.form.controls['hosts'].value))
45 getNotEmptyHosts(this.form.controls['domains'].value)
46 )
47 this.form.reset() 45 this.form.reset()
48 this.hide() 46 this.hide()
49 } 47 }
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
index e6f4f6b92..cbe6b7e4f 100644
--- a/server/controllers/api/server/follows.ts
+++ b/server/controllers/api/server/follows.ts
@@ -29,6 +29,7 @@ import {
29 removeFollowingValidator 29 removeFollowingValidator
30} from '../../../middlewares/validators' 30} from '../../../middlewares/validators'
31import { ActorFollowModel } from '../../../models/actor/actor-follow' 31import { ActorFollowModel } from '../../../models/actor/actor-follow'
32import { ServerFollowCreate } from '@shared/models'
32 33
33const serverFollowsRouter = express.Router() 34const serverFollowsRouter = express.Router()
34serverFollowsRouter.get('/following', 35serverFollowsRouter.get('/following',
@@ -45,10 +46,10 @@ serverFollowsRouter.post('/following',
45 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), 46 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
46 followValidator, 47 followValidator,
47 setBodyHostsPort, 48 setBodyHostsPort,
48 asyncMiddleware(followInstance) 49 asyncMiddleware(addFollow)
49) 50)
50 51
51serverFollowsRouter.delete('/following/:host', 52serverFollowsRouter.delete('/following/:hostOrHandle',
52 authenticate, 53 authenticate,
53 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), 54 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
54 asyncMiddleware(removeFollowingValidator), 55 asyncMiddleware(removeFollowingValidator),
@@ -125,8 +126,8 @@ async function listFollowers (req: express.Request, res: express.Response) {
125 return res.json(getFormattedObjects(resultList.data, resultList.total)) 126 return res.json(getFormattedObjects(resultList.data, resultList.total))
126} 127}
127 128
128async function followInstance (req: express.Request, res: express.Response) { 129async function addFollow (req: express.Request, res: express.Response) {
129 const hosts = req.body.hosts as string[] 130 const { hosts, handles } = req.body as ServerFollowCreate
130 const follower = await getServerActor() 131 const follower = await getServerActor()
131 132
132 for (const host of hosts) { 133 for (const host of hosts) {
@@ -139,6 +140,18 @@ async function followInstance (req: express.Request, res: express.Response) {
139 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload }) 140 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
140 } 141 }
141 142
143 for (const handle of handles) {
144 const [ name, host ] = handle.split('@')
145
146 const payload = {
147 host,
148 name,
149 followerActorId: follower.id
150 }
151
152 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
153 }
154
142 return res.status(HttpStatusCode.NO_CONTENT_204).end() 155 return res.status(HttpStatusCode.NO_CONTENT_204).end()
143} 156}
144 157
diff --git a/server/helpers/custom-validators/follows.ts b/server/helpers/custom-validators/follows.ts
index fbef7ad87..8f65552c3 100644
--- a/server/helpers/custom-validators/follows.ts
+++ b/server/helpers/custom-validators/follows.ts
@@ -1,4 +1,4 @@
1import { exists } from './misc' 1import { exists, isArray } from './misc'
2import { FollowState } from '@shared/models' 2import { FollowState } from '@shared/models'
3 3
4function isFollowStateValid (value: FollowState) { 4function isFollowStateValid (value: FollowState) {
@@ -7,8 +7,24 @@ function isFollowStateValid (value: FollowState) {
7 return value === 'pending' || value === 'accepted' 7 return value === 'pending' || value === 'accepted'
8} 8}
9 9
10function isRemoteHandleValid (value: string) {
11 if (!exists(value)) return false
12 if (typeof value !== 'string') return false
13
14 return value.includes('@')
15}
16
17function isEachUniqueHandleValid (handles: string[]) {
18 return isArray(handles) &&
19 handles.every(handle => {
20 return isRemoteHandleValid(handle) && handles.indexOf(handle) === handles.lastIndexOf(handle)
21 })
22}
23
10// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
11 25
12export { 26export {
13 isFollowStateValid 27 isFollowStateValid,
28 isRemoteHandleValid,
29 isEachUniqueHandleValid
14} 30}
diff --git a/server/helpers/custom-validators/servers.ts b/server/helpers/custom-validators/servers.ts
index adf1ea497..c0f8b6aeb 100644
--- a/server/helpers/custom-validators/servers.ts
+++ b/server/helpers/custom-validators/servers.ts
@@ -19,7 +19,6 @@ function isHostValid (host: string) {
19 19
20function isEachUniqueHostValid (hosts: string[]) { 20function isEachUniqueHostValid (hosts: string[]) {
21 return isArray(hosts) && 21 return isArray(hosts) &&
22 hosts.length !== 0 &&
23 hosts.every(host => { 22 hosts.every(host => {
24 return isHostValid(host) && hosts.indexOf(host) === hosts.lastIndexOf(host) 23 return isHostValid(host) && hosts.indexOf(host) === hosts.lastIndexOf(host)
25 }) 24 })
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts
index cd117f571..28ff5225a 100644
--- a/server/lib/activitypub/crawl.ts
+++ b/server/lib/activitypub/crawl.ts
@@ -1,3 +1,4 @@
1import { retryTransactionWrapper } from '@server/helpers/database-utils'
1import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
2import { URL } from 'url' 3import { URL } from 'url'
3import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' 4import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
@@ -51,7 +52,7 @@ async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction
51 } 52 }
52 } 53 }
53 54
54 if (cleaner) await cleaner(startDate) 55 if (cleaner) await retryTransactionWrapper(cleaner, startDate)
55} 56}
56 57
57export { 58export {
diff --git a/server/lib/activitypub/follow.ts b/server/lib/activitypub/follow.ts
index c1bd667e0..741b54df5 100644
--- a/server/lib/activitypub/follow.ts
+++ b/server/lib/activitypub/follow.ts
@@ -31,6 +31,21 @@ async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transact
31 } 31 }
32} 32}
33 33
34// If we only have an host, use a default account handle
35function getRemoteNameAndHost (handleOrHost: string) {
36 let name = SERVER_ACTOR_NAME
37 let host = handleOrHost
38
39 const splitted = handleOrHost.split('@')
40 if (splitted.length === 2) {
41 name = splitted[0]
42 host = splitted[1]
43 }
44
45 return { name, host }
46}
47
34export { 48export {
35 autoFollowBackIfNeeded 49 autoFollowBackIfNeeded,
50 getRemoteNameAndHost
36} 51}
diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts
index 05cc66c38..16abdd096 100644
--- a/server/middlewares/validators/follows.ts
+++ b/server/middlewares/validators/follows.ts
@@ -1,7 +1,8 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { isFollowStateValid } from '@server/helpers/custom-validators/follows' 3import { isEachUniqueHandleValid, isFollowStateValid, isRemoteHandleValid } from '@server/helpers/custom-validators/follows'
4import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors' 4import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors'
5import { getRemoteNameAndHost } from '@server/lib/activitypub/follow'
5import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
6import { MActorFollowActorsDefault } from '@server/types/models' 7import { MActorFollowActorsDefault } from '@server/types/models'
7import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' 8import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
@@ -9,10 +10,11 @@ import { isTestInstance } from '../../helpers/core-utils'
9import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' 10import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
10import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' 11import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
11import { logger } from '../../helpers/logger' 12import { logger } from '../../helpers/logger'
12import { SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' 13import { WEBSERVER } from '../../initializers/constants'
13import { ActorModel } from '../../models/actor/actor' 14import { ActorModel } from '../../models/actor/actor'
14import { ActorFollowModel } from '../../models/actor/actor-follow' 15import { ActorFollowModel } from '../../models/actor/actor-follow'
15import { areValidationErrors } from './shared' 16import { areValidationErrors } from './shared'
17import { ServerFollowCreate } from '@shared/models'
16 18
17const listFollowsValidator = [ 19const listFollowsValidator = [
18 query('state') 20 query('state')
@@ -30,29 +32,46 @@ const listFollowsValidator = [
30] 32]
31 33
32const followValidator = [ 34const followValidator = [
33 body('hosts').custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'), 35 body('hosts')
36 .toArray()
37 .custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'),
38
39 body('handles')
40 .toArray()
41 .custom(isEachUniqueHandleValid).withMessage('Should have an array of handles'),
34 42
35 (req: express.Request, res: express.Response, next: express.NextFunction) => { 43 (req: express.Request, res: express.Response, next: express.NextFunction) => {
36 // Force https if the administrator wants to make friends 44 // Force https if the administrator wants to follow remote actors
37 if (isTestInstance() === false && WEBSERVER.SCHEME === 'http') { 45 if (isTestInstance() === false && WEBSERVER.SCHEME === 'http') {
38 return res 46 return res
39 .status(HttpStatusCode.INTERNAL_SERVER_ERROR_500) 47 .status(HttpStatusCode.INTERNAL_SERVER_ERROR_500)
40 .json({ 48 .json({
41 error: 'Cannot follow on a non HTTPS web server.' 49 error: 'Cannot follow on a non HTTPS web server.'
42 }) 50 })
43 .end()
44 } 51 }
45 52
46 logger.debug('Checking follow parameters', { parameters: req.body }) 53 logger.debug('Checking follow parameters', { parameters: req.body })
47 54
48 if (areValidationErrors(req, res)) return 55 if (areValidationErrors(req, res)) return
49 56
57 const body: ServerFollowCreate = req.body
58 if (body.hosts.length === 0 && body.handles.length === 0) {
59
60 return res
61 .status(HttpStatusCode.BAD_REQUEST_400)
62 .json({
63 error: 'You must provide at least one handle or one host.'
64 })
65 }
66
50 return next() 67 return next()
51 } 68 }
52] 69]
53 70
54const removeFollowingValidator = [ 71const removeFollowingValidator = [
55 param('host').custom(isHostValid).withMessage('Should have a valid host'), 72 param('hostOrHandle')
73 .custom(value => isHostValid(value) || isRemoteHandleValid(value))
74 .withMessage('Should have a valid host/handle'),
56 75
57 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 76 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
58 logger.debug('Checking unfollowing parameters', { parameters: req.params }) 77 logger.debug('Checking unfollowing parameters', { parameters: req.params })
@@ -60,12 +79,14 @@ const removeFollowingValidator = [
60 if (areValidationErrors(req, res)) return 79 if (areValidationErrors(req, res)) return
61 80
62 const serverActor = await getServerActor() 81 const serverActor = await getServerActor()
63 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host) 82
83 const { name, host } = getRemoteNameAndHost(req.params.hostOrHandle)
84 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, name, host)
64 85
65 if (!follow) { 86 if (!follow) {
66 return res.fail({ 87 return res.fail({
67 status: HttpStatusCode.NOT_FOUND_404, 88 status: HttpStatusCode.NOT_FOUND_404,
68 message: `Following ${req.params.host} not found.` 89 message: `Follow ${req.params.hostOrHandle} not found.`
69 }) 90 })
70 } 91 }
71 92
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index 3a09e51d6..83c00a22d 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -324,13 +324,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
324 324
325 const followWhere = state ? { state } : {} 325 const followWhere = state ? { state } : {}
326 const followingWhere: WhereOptions = {} 326 const followingWhere: WhereOptions = {}
327 const followingServerWhere: WhereOptions = {}
328 327
329 if (search) { 328 if (search) {
330 Object.assign(followingServerWhere, { 329 Object.assign(followWhere, {
331 host: { 330 [Op.or]: [
332 [Op.iLike]: '%' + search + '%' 331 searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
333 } 332 searchAttribute(options.search, '$ActorFollowing.Server.host$')
333 ]
334 }) 334 })
335 } 335 }
336 336
@@ -361,8 +361,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
361 include: [ 361 include: [
362 { 362 {
363 model: ServerModel, 363 model: ServerModel,
364 required: true, 364 required: true
365 where: followingServerWhere
366 } 365 }
367 ] 366 ]
368 } 367 }
@@ -391,13 +390,13 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
391 390
392 const followWhere = state ? { state } : {} 391 const followWhere = state ? { state } : {}
393 const followerWhere: WhereOptions = {} 392 const followerWhere: WhereOptions = {}
394 const followerServerWhere: WhereOptions = {}
395 393
396 if (search) { 394 if (search) {
397 Object.assign(followerServerWhere, { 395 Object.assign(followWhere, {
398 host: { 396 [Op.or]: [
399 [Op.iLike]: '%' + search + '%' 397 searchAttribute(search, '$ActorFollower.preferredUsername$'),
400 } 398 searchAttribute(search, '$ActorFollower.Server.host$')
399 ]
401 }) 400 })
402 } 401 }
403 402
@@ -420,8 +419,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
420 include: [ 419 include: [
421 { 420 {
422 model: ServerModel, 421 model: ServerModel,
423 required: true, 422 required: true
424 where: followerServerWhere
425 } 423 }
426 ] 424 ]
427 }, 425 },
diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts
index 30b251f0f..054f71c8c 100644
--- a/server/models/video/sql/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/videos-id-list-query-builder.ts
@@ -304,16 +304,16 @@ export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
304 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) { 304 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) {
305 let query = 305 let query =
306 '(' + 306 '(' +
307 ' EXISTS (' + 307 ' EXISTS (' + // Videos shared by actors we follow
308 ' SELECT 1 FROM "videoShare" ' + 308 ' SELECT 1 FROM "videoShare" ' +
309 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' + 309 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
310 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' + 310 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
311 ' WHERE "videoShare"."videoId" = "video"."id"' + 311 ' WHERE "videoShare"."videoId" = "video"."id"' +
312 ' )' + 312 ' )' +
313 ' OR' + 313 ' OR' +
314 ' EXISTS (' + 314 ' EXISTS (' + // Videos published by accounts we follow
315 ' SELECT 1 from "actorFollow" ' + 315 ' SELECT 1 from "actorFollow" ' +
316 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' + 316 ' WHERE "actorFollow"."targetActorId" = "account"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
317 ' AND "actorFollow"."state" = \'accepted\'' + 317 ' AND "actorFollow"."state" = \'accepted\'' +
318 ' )' 318 ' )'
319 319
diff --git a/server/tests/api/check-params/follows.ts b/server/tests/api/check-params/follows.ts
index dfe3f226d..2bc9f6b96 100644
--- a/server/tests/api/check-params/follows.ts
+++ b/server/tests/api/check-params/follows.ts
@@ -32,19 +32,13 @@ describe('Test server follows API validators', function () {
32 let userAccessToken = null 32 let userAccessToken = null
33 33
34 before(async function () { 34 before(async function () {
35 const user = { 35 userAccessToken = await server.users.generateUserAndToken('user1')
36 username: 'user1',
37 password: 'password'
38 }
39
40 await server.users.create({ username: user.username, password: user.password })
41 userAccessToken = await server.login.getAccessToken(user)
42 }) 36 })
43 37
44 describe('When adding follows', function () { 38 describe('When adding follows', function () {
45 const path = '/api/v1/server/following' 39 const path = '/api/v1/server/following'
46 40
47 it('Should fail without hosts', async function () { 41 it('Should fail with nothing', async function () {
48 await makePostBodyRequest({ 42 await makePostBodyRequest({
49 url: server.url, 43 url: server.url,
50 path, 44 path,
@@ -53,41 +47,51 @@ describe('Test server follows API validators', function () {
53 }) 47 })
54 }) 48 })
55 49
56 it('Should fail if hosts is not an array', async function () { 50 it('Should fail if hosts is not composed by hosts', async function () {
57 await makePostBodyRequest({ 51 await makePostBodyRequest({
58 url: server.url, 52 url: server.url,
59 path, 53 path,
54 fields: { hosts: [ 'localhost:9002', 'localhost:coucou' ] },
60 token: server.accessToken, 55 token: server.accessToken,
61 fields: { hosts: 'localhost:9002' },
62 expectedStatus: HttpStatusCode.BAD_REQUEST_400 56 expectedStatus: HttpStatusCode.BAD_REQUEST_400
63 }) 57 })
64 }) 58 })
65 59
66 it('Should fail if the array is not composed by hosts', async function () { 60 it('Should fail if hosts is composed with http schemes', async function () {
67 await makePostBodyRequest({ 61 await makePostBodyRequest({
68 url: server.url, 62 url: server.url,
69 path, 63 path,
70 fields: { hosts: [ 'localhost:9002', 'localhost:coucou' ] }, 64 fields: { hosts: [ 'localhost:9002', 'http://localhost:9003' ] },
71 token: server.accessToken, 65 token: server.accessToken,
72 expectedStatus: HttpStatusCode.BAD_REQUEST_400 66 expectedStatus: HttpStatusCode.BAD_REQUEST_400
73 }) 67 })
74 }) 68 })
75 69
76 it('Should fail if the array is composed with http schemes', async function () { 70 it('Should fail if hosts are not unique', async function () {
77 await makePostBodyRequest({ 71 await makePostBodyRequest({
78 url: server.url, 72 url: server.url,
79 path, 73 path,
80 fields: { hosts: [ 'localhost:9002', 'http://localhost:9003' ] }, 74 fields: { urls: [ 'localhost:9002', 'localhost:9002' ] },
81 token: server.accessToken, 75 token: server.accessToken,
82 expectedStatus: HttpStatusCode.BAD_REQUEST_400 76 expectedStatus: HttpStatusCode.BAD_REQUEST_400
83 }) 77 })
84 }) 78 })
85 79
86 it('Should fail if hosts are not unique', async function () { 80 it('Should fail if handles is not composed by handles', async function () {
87 await makePostBodyRequest({ 81 await makePostBodyRequest({
88 url: server.url, 82 url: server.url,
89 path, 83 path,
90 fields: { urls: [ 'localhost:9002', 'localhost:9002' ] }, 84 fields: { handles: [ 'hello@example.com', 'localhost:9001' ] },
85 token: server.accessToken,
86 expectedStatus: HttpStatusCode.BAD_REQUEST_400
87 })
88 })
89
90 it('Should fail if handles are not unique', async function () {
91 await makePostBodyRequest({
92 url: server.url,
93 path,
94 fields: { urls: [ 'hello@example.com', 'hello@example.com' ] },
91 token: server.accessToken, 95 token: server.accessToken,
92 expectedStatus: HttpStatusCode.BAD_REQUEST_400 96 expectedStatus: HttpStatusCode.BAD_REQUEST_400
93 }) 97 })
diff --git a/server/tests/api/moderation/blocklist.ts b/server/tests/api/moderation/blocklist.ts
index 8ed5ad9e5..089af8b15 100644
--- a/server/tests/api/moderation/blocklist.ts
+++ b/server/tests/api/moderation/blocklist.ts
@@ -690,7 +690,7 @@ describe('Test blocklist', function () {
690 const now = new Date() 690 const now = new Date()
691 await servers[1].follows.unfollow({ target: servers[0] }) 691 await servers[1].follows.unfollow({ target: servers[0] })
692 await waitJobs(servers) 692 await waitJobs(servers)
693 await servers[1].follows.follow({ targets: [ servers[0].host ] }) 693 await servers[1].follows.follow({ hosts: [ servers[0].host ] })
694 694
695 await waitJobs(servers) 695 await waitJobs(servers)
696 696
@@ -751,7 +751,7 @@ describe('Test blocklist', function () {
751 const now = new Date() 751 const now = new Date()
752 await servers[1].follows.unfollow({ target: servers[0] }) 752 await servers[1].follows.unfollow({ target: servers[0] })
753 await waitJobs(servers) 753 await waitJobs(servers)
754 await servers[1].follows.follow({ targets: [ servers[0].host ] }) 754 await servers[1].follows.follow({ hosts: [ servers[0].host ] })
755 755
756 await waitJobs(servers) 756 await waitJobs(servers)
757 757
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index 6e8f8a2b4..6f74709b3 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -368,7 +368,7 @@ describe('Test moderation notifications', function () {
368 it('Should send a notification only to admin when there is a new instance follower', async function () { 368 it('Should send a notification only to admin when there is a new instance follower', async function () {
369 this.timeout(20000) 369 this.timeout(20000)
370 370
371 await servers[2].follows.follow({ targets: [ servers[0].url ] }) 371 await servers[2].follows.follow({ hosts: [ servers[0].url ] })
372 372
373 await waitJobs(servers) 373 await waitJobs(servers)
374 374
@@ -393,7 +393,7 @@ describe('Test moderation notifications', function () {
393 } 393 }
394 await servers[0].config.updateCustomSubConfig({ newConfig: config }) 394 await servers[0].config.updateCustomSubConfig({ newConfig: config })
395 395
396 await servers[2].follows.follow({ targets: [ servers[0].url ] }) 396 await servers[2].follows.follow({ hosts: [ servers[0].url ] })
397 397
398 await waitJobs(servers) 398 await waitJobs(servers)
399 399
diff --git a/server/tests/api/redundancy/redundancy-constraints.ts b/server/tests/api/redundancy/redundancy-constraints.ts
index 25cd11658..933a2c776 100644
--- a/server/tests/api/redundancy/redundancy-constraints.ts
+++ b/server/tests/api/redundancy/redundancy-constraints.ts
@@ -75,7 +75,7 @@ describe('Test redundancy constraints', function () {
75 await waitJobs(servers) 75 await waitJobs(servers)
76 76
77 // Server 1 and server 2 follow each other 77 // Server 1 and server 2 follow each other
78 await remoteServer.follows.follow({ targets: [ localServer.url ] }) 78 await remoteServer.follows.follow({ hosts: [ localServer.url ] })
79 await waitJobs(servers) 79 await waitJobs(servers)
80 await remoteServer.redundancy.updateRedundancy({ host: localServer.host, redundancyAllowed: true }) 80 await remoteServer.redundancy.updateRedundancy({ host: localServer.host, redundancyAllowed: true })
81 81
@@ -161,7 +161,7 @@ describe('Test redundancy constraints', function () {
161 it('Should have redundancy on server 1 and on server 2 with followings filter now server 2 follows server 1', async function () { 161 it('Should have redundancy on server 1 and on server 2 with followings filter now server 2 follows server 1', async function () {
162 this.timeout(120000) 162 this.timeout(120000)
163 163
164 await localServer.follows.follow({ targets: [ remoteServer.url ] }) 164 await localServer.follows.follow({ hosts: [ remoteServer.url ] })
165 await waitJobs(servers) 165 await waitJobs(servers)
166 166
167 await uploadWrapper('video 4 server 2') 167 await uploadWrapper('video 4 server 2')
diff --git a/server/tests/api/server/auto-follows.ts b/server/tests/api/server/auto-follows.ts
index 8dca2e5e5..ce7b51925 100644
--- a/server/tests/api/server/auto-follows.ts
+++ b/server/tests/api/server/auto-follows.ts
@@ -33,7 +33,7 @@ async function checkFollow (follower: PeerTubeServer, following: PeerTubeServer,
33} 33}
34 34
35async function server1Follows2 (servers: PeerTubeServer[]) { 35async function server1Follows2 (servers: PeerTubeServer[]) {
36 await servers[0].follows.follow({ targets: [ servers[1].host ] }) 36 await servers[0].follows.follow({ hosts: [ servers[1].host ] })
37 37
38 await waitJobs(servers) 38 await waitJobs(servers)
39} 39}
diff --git a/server/tests/api/server/follows-moderation.ts b/server/tests/api/server/follows-moderation.ts
index 0aa328c5a..921f51043 100644
--- a/server/tests/api/server/follows-moderation.ts
+++ b/server/tests/api/server/follows-moderation.ts
@@ -60,7 +60,7 @@ describe('Test follows moderation', function () {
60 it('Should have server 1 following server 2', async function () { 60 it('Should have server 1 following server 2', async function () {
61 this.timeout(30000) 61 this.timeout(30000)
62 62
63 await commands[0].follow({ targets: [ servers[1].url ] }) 63 await commands[0].follow({ hosts: [ servers[1].url ] })
64 64
65 await waitJobs(servers) 65 await waitJobs(servers)
66 }) 66 })
@@ -95,7 +95,7 @@ describe('Test follows moderation', function () {
95 95
96 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) 96 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
97 97
98 await commands[0].follow({ targets: [ servers[1].url ] }) 98 await commands[0].follow({ hosts: [ servers[1].url ] })
99 await waitJobs(servers) 99 await waitJobs(servers)
100 100
101 await checkNoFollowers(servers) 101 await checkNoFollowers(servers)
@@ -115,7 +115,7 @@ describe('Test follows moderation', function () {
115 115
116 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) 116 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
117 117
118 await commands[0].follow({ targets: [ servers[1].url ] }) 118 await commands[0].follow({ hosts: [ servers[1].url ] })
119 await waitJobs(servers) 119 await waitJobs(servers)
120 120
121 await checkServer1And2HasFollowers(servers) 121 await checkServer1And2HasFollowers(servers)
@@ -139,7 +139,7 @@ describe('Test follows moderation', function () {
139 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig }) 139 await servers[1].config.updateCustomSubConfig({ newConfig: subConfig })
140 await servers[2].config.updateCustomSubConfig({ newConfig: subConfig }) 140 await servers[2].config.updateCustomSubConfig({ newConfig: subConfig })
141 141
142 await commands[0].follow({ targets: [ servers[1].url ] }) 142 await commands[0].follow({ hosts: [ servers[1].url ] })
143 await waitJobs(servers) 143 await waitJobs(servers)
144 144
145 await checkServer1And2HasFollowers(servers, 'pending') 145 await checkServer1And2HasFollowers(servers, 'pending')
@@ -157,7 +157,7 @@ describe('Test follows moderation', function () {
157 it('Should reject another follower', async function () { 157 it('Should reject another follower', async function () {
158 this.timeout(20000) 158 this.timeout(20000)
159 159
160 await commands[0].follow({ targets: [ servers[2].url ] }) 160 await commands[0].follow({ hosts: [ servers[2].url ] })
161 await waitJobs(servers) 161 await waitJobs(servers)
162 162
163 { 163 {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index ff8f880a6..a616edcff 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -8,308 +8,369 @@ import {
8 createMultipleServers, 8 createMultipleServers,
9 dateIsValid, 9 dateIsValid,
10 expectAccountFollows, 10 expectAccountFollows,
11 FollowsCommand, 11 expectChannelsFollows,
12 PeerTubeServer, 12 PeerTubeServer,
13 setAccessTokensToServers, 13 setAccessTokensToServers,
14 testCaptionFile, 14 testCaptionFile,
15 waitJobs 15 waitJobs
16} from '@shared/extra-utils' 16} from '@shared/extra-utils'
17import { Video, VideoPrivacy } from '@shared/models' 17import { VideoCreateResult, VideoPrivacy } from '@shared/models'
18 18
19const expect = chai.expect 19const expect = chai.expect
20 20
21describe('Test follows', function () { 21describe('Test follows', function () {
22 let servers: PeerTubeServer[] = [] 22 let servers: PeerTubeServer[] = []
23 let followsCommands: FollowsCommand[]
24 23
25 before(async function () { 24 before(async function () {
26 this.timeout(30000) 25 this.timeout(30000)
27 26
28 servers = await createMultipleServers(3) 27 servers = await createMultipleServers(3)
29 followsCommands = servers.map(s => s.follows)
30 28
31 // Get the access tokens 29 // Get the access tokens
32 await setAccessTokensToServers(servers) 30 await setAccessTokensToServers(servers)
33 }) 31 })
34 32
35 it('Should not have followers', async function () { 33 describe('Data propagation after follow', function () {
36 for (const server of servers) {
37 const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' })
38 expect(body.total).to.equal(0)
39
40 const follows = body.data
41 expect(follows).to.be.an('array')
42 expect(follows.length).to.equal(0)
43 }
44 })
45 34
46 it('Should not have following', async function () { 35 it('Should not have followers/followings', async function () {
47 for (const server of servers) { 36 for (const server of servers) {
48 const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) 37 const bodies = await Promise.all([
49 expect(body.total).to.equal(0) 38 server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }),
39 server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
40 ])
50 41
51 const follows = body.data 42 for (const body of bodies) {
52 expect(follows).to.be.an('array') 43 expect(body.total).to.equal(0)
53 expect(follows.length).to.equal(0)
54 }
55 })
56 44
57 it('Should have server 1 following server 2 and 3', async function () { 45 const follows = body.data
58 this.timeout(30000) 46 expect(follows).to.be.an('array')
47 expect(follows).to.have.lengthOf(0)
48 }
49 }
50 })
59 51
60 await followsCommands[0].follow({ targets: [ servers[1].url, servers[2].url ] }) 52 it('Should have server 1 following root account of server 2 and server 3', async function () {
53 this.timeout(30000)
61 54
62 await waitJobs(servers) 55 await servers[0].follows.follow({
63 }) 56 hosts: [ servers[2].url ],
57 handles: [ 'root@' + servers[1].host ]
58 })
64 59
65 it('Should have 2 followings on server 1', async function () { 60 await waitJobs(servers)
66 const body = await followsCommands[0].getFollowings({ start: 0, count: 1, sort: 'createdAt' }) 61 })
67 expect(body.total).to.equal(2)
68 62
69 let follows = body.data 63 it('Should have 2 followings on server 1', async function () {
70 expect(follows).to.be.an('array') 64 const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' })
71 expect(follows.length).to.equal(1) 65 expect(body.total).to.equal(2)
72 66
73 const body2 = await followsCommands[0].getFollowings({ start: 1, count: 1, sort: 'createdAt' }) 67 let follows = body.data
74 follows = follows.concat(body2.data) 68 expect(follows).to.be.an('array')
69 expect(follows).to.have.lengthOf(1)
75 70
76 const server2Follow = follows.find(f => f.following.host === 'localhost:' + servers[1].port) 71 const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' })
77 const server3Follow = follows.find(f => f.following.host === 'localhost:' + servers[2].port) 72 follows = follows.concat(body2.data)
78 73
79 expect(server2Follow).to.not.be.undefined 74 const server2Follow = follows.find(f => f.following.host === servers[1].host)
80 expect(server3Follow).to.not.be.undefined 75 const server3Follow = follows.find(f => f.following.host === servers[2].host)
81 expect(server2Follow.state).to.equal('accepted')
82 expect(server3Follow.state).to.equal('accepted')
83 })
84 76
85 it('Should search/filter followings on server 1', async function () { 77 expect(server2Follow).to.not.be.undefined
86 const sort = 'createdAt' 78 expect(server2Follow.following.name).to.equal('root')
87 const start = 0 79 expect(server2Follow.state).to.equal('accepted')
88 const count = 1
89 80
90 { 81 expect(server3Follow).to.not.be.undefined
91 const search = ':' + servers[1].port 82 expect(server3Follow.following.name).to.equal('peertube')
83 expect(server3Follow.state).to.equal('accepted')
84 })
92 85
93 { 86 it('Should have 0 followings on server 2 and 3', async function () {
94 const body = await followsCommands[0].getFollowings({ start, count, sort, search }) 87 for (const server of [ servers[1], servers[2] ]) {
95 expect(body.total).to.equal(1) 88 const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
89 expect(body.total).to.equal(0)
96 90
97 const follows = body.data 91 const follows = body.data
98 expect(follows.length).to.equal(1) 92 expect(follows).to.be.an('array')
99 expect(follows[0].following.host).to.equal('localhost:' + servers[1].port) 93 expect(follows).to.have.lengthOf(0)
100 } 94 }
95 })
101 96
102 { 97 it('Should have 1 followers on server 3', async function () {
103 const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted' }) 98 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
104 expect(body.total).to.equal(1) 99 expect(body.total).to.equal(1)
105 expect(body.data).to.have.lengthOf(1) 100
101 const follows = body.data
102 expect(follows).to.be.an('array')
103 expect(follows).to.have.lengthOf(1)
104 expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port)
105 })
106
107 it('Should have 0 followers on server 1 and 2', async function () {
108 for (const server of [ servers[0], servers[1] ]) {
109 const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' })
110 expect(body.total).to.equal(0)
111
112 const follows = body.data
113 expect(follows).to.be.an('array')
114 expect(follows).to.have.lengthOf(0)
106 } 115 }
116 })
117
118 it('Should search/filter followings on server 1', async function () {
119 const sort = 'createdAt'
120 const start = 0
121 const count = 1
107 122
108 { 123 {
109 const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) 124 const search = ':' + servers[1].port
110 expect(body.total).to.equal(0) 125
111 expect(body.data).to.have.lengthOf(0) 126 {
127 const body = await servers[0].follows.getFollowings({ start, count, sort, search })
128 expect(body.total).to.equal(1)
129
130 const follows = body.data
131 expect(follows).to.have.lengthOf(1)
132 expect(follows[0].following.host).to.equal(servers[1].host)
133 }
134
135 {
136 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' })
137 expect(body.total).to.equal(1)
138 expect(body.data).to.have.lengthOf(1)
139 }
140
141 {
142 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
143 expect(body.total).to.equal(1)
144 expect(body.data).to.have.lengthOf(1)
145 }
146
147 {
148 const body = await servers[0].follows.getFollowings({
149 start,
150 count,
151 sort,
152 search,
153 state: 'accepted',
154 actorType: 'Application'
155 })
156 expect(body.total).to.equal(0)
157 expect(body.data).to.have.lengthOf(0)
158 }
159
160 {
161 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' })
162 expect(body.total).to.equal(0)
163 expect(body.data).to.have.lengthOf(0)
164 }
112 } 165 }
113 166
114 { 167 {
115 const body = await followsCommands[0].getFollowings({ 168 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' })
116 start,
117 count,
118 sort,
119 search,
120 state: 'accepted',
121 actorType: 'Application'
122 })
123 expect(body.total).to.equal(1) 169 expect(body.total).to.equal(1)
124 expect(body.data).to.have.lengthOf(1) 170 expect(body.data).to.have.lengthOf(1)
125 } 171 }
126 172
127 { 173 {
128 const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'pending' }) 174 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' })
129 expect(body.total).to.equal(0) 175 expect(body.total).to.equal(0)
176
130 expect(body.data).to.have.lengthOf(0) 177 expect(body.data).to.have.lengthOf(0)
131 } 178 }
132 } 179 })
133 180
134 { 181 it('Should search/filter followers on server 2', async function () {
135 const body = await followsCommands[0].getFollowings({ start, count, sort, search: 'bla' }) 182 const start = 0
136 expect(body.total).to.equal(0) 183 const count = 5
184 const sort = 'createdAt'
137 185
138 expect(body.data.length).to.equal(0) 186 {
139 } 187 const search = servers[0].port + ''
140 })
141 188
142 it('Should have 0 followings on server 2 and 3', async function () { 189 {
143 for (const server of [ servers[1], servers[2] ]) { 190 const body = await servers[2].follows.getFollowers({ start, count, sort, search })
144 const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) 191 expect(body.total).to.equal(1)
145 expect(body.total).to.equal(0)
146 192
147 const follows = body.data 193 const follows = body.data
148 expect(follows).to.be.an('array') 194 expect(follows).to.have.lengthOf(1)
149 expect(follows.length).to.equal(0) 195 expect(follows[0].following.host).to.equal(servers[2].host)
150 } 196 }
151 })
152 197
153 it('Should have 1 followers on server 2 and 3', async function () { 198 {
154 for (const server of [ servers[1], servers[2] ]) { 199 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' })
155 const body = await server.follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) 200 expect(body.total).to.equal(1)
156 expect(body.total).to.equal(1) 201 expect(body.data).to.have.lengthOf(1)
202 }
157 203
158 const follows = body.data 204 {
159 expect(follows).to.be.an('array') 205 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
160 expect(follows.length).to.equal(1) 206 expect(body.total).to.equal(0)
161 expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port) 207 expect(body.data).to.have.lengthOf(0)
162 } 208 }
163 })
164 209
165 it('Should search/filter followers on server 2', async function () { 210 {
166 const start = 0 211 const body = await servers[2].follows.getFollowers({
167 const count = 5 212 start,
168 const sort = 'createdAt' 213 count,
214 sort,
215 search,
216 state: 'accepted',
217 actorType: 'Application'
218 })
219 expect(body.total).to.equal(1)
220 expect(body.data).to.have.lengthOf(1)
221 }
169 222
170 { 223 {
171 const search = servers[0].port + '' 224 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' })
225 expect(body.total).to.equal(0)
226 expect(body.data).to.have.lengthOf(0)
227 }
228 }
172 229
173 { 230 {
174 const body = await followsCommands[2].getFollowers({ start, count, sort, search }) 231 const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' })
175 expect(body.total).to.equal(1) 232 expect(body.total).to.equal(0)
176 233
177 const follows = body.data 234 const follows = body.data
178 expect(follows.length).to.equal(1) 235 expect(follows).to.have.lengthOf(0)
179 expect(follows[0].following.host).to.equal('localhost:' + servers[2].port)
180 } 236 }
237 })
181 238
182 { 239 it('Should have the correct follows counts', async function () {
183 const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted' }) 240 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
184 expect(body.total).to.equal(1) 241 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
185 expect(body.data).to.have.lengthOf(1) 242 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
186 }
187 243
188 { 244 // Server 2 and 3 does not know server 1 follow another server (there was not a refresh)
189 const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) 245 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
190 expect(body.total).to.equal(0) 246 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
191 expect(body.data).to.have.lengthOf(0) 247 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
192 }
193 248
194 { 249 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
195 const body = await followsCommands[2].getFollowers({ 250 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
196 start, 251 })
197 count,
198 sort,
199 search,
200 state: 'accepted',
201 actorType: 'Application'
202 })
203 expect(body.total).to.equal(1)
204 expect(body.data).to.have.lengthOf(1)
205 }
206 252
207 { 253 it('Should unfollow server 3 on server 1', async function () {
208 const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'pending' }) 254 this.timeout(15000)
209 expect(body.total).to.equal(0)
210 expect(body.data).to.have.lengthOf(0)
211 }
212 }
213 255
214 { 256 await servers[0].follows.unfollow({ target: servers[2] })
215 const body = await followsCommands[2].getFollowers({ start, count, sort, search: 'bla' }) 257
216 expect(body.total).to.equal(0) 258 await waitJobs(servers)
259 })
260
261 it('Should not follow server 3 on server 1 anymore', async function () {
262 const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' })
263 expect(body.total).to.equal(1)
217 264
218 const follows = body.data 265 const follows = body.data
219 expect(follows.length).to.equal(0) 266 expect(follows).to.be.an('array')
220 } 267 expect(follows).to.have.lengthOf(1)
221 })
222 268
223 it('Should have 0 followers on server 1', async function () { 269 expect(follows[0].following.host).to.equal(servers[1].host)
224 const body = await followsCommands[0].getFollowers({ start: 0, count: 5, sort: 'createdAt' }) 270 })
225 expect(body.total).to.equal(0)
226 271
227 const follows = body.data 272 it('Should not have server 1 as follower on server 3 anymore', async function () {
228 expect(follows).to.be.an('array') 273 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
229 expect(follows.length).to.equal(0) 274 expect(body.total).to.equal(0)
230 })
231 275
232 it('Should have the correct follows counts', async function () { 276 const follows = body.data
233 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 }) 277 expect(follows).to.be.an('array')
234 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) 278 expect(follows).to.have.lengthOf(0)
235 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) 279 })
236 280
237 // Server 2 and 3 does not know server 1 follow another server (there was not a refresh) 281 it('Should have the correct follows counts after the unfollow', async function () {
238 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) 282 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
239 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) 283 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
284 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
240 285
241 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) 286 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
242 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) 287 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
243 }) 288 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
244 289
245 it('Should unfollow server 3 on server 1', async function () { 290 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 })
246 this.timeout(5000) 291 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
292 })
247 293
248 await followsCommands[0].unfollow({ target: servers[2] }) 294 it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () {
295 this.timeout(60000)
249 296
250 await waitJobs(servers) 297 await servers[1].videos.upload({ attributes: { name: 'server2' } })
251 }) 298 await servers[2].videos.upload({ attributes: { name: 'server3' } })
252 299
253 it('Should not follow server 3 on server 1 anymore', async function () { 300 await waitJobs(servers)
254 const body = await followsCommands[0].getFollowings({ start: 0, count: 2, sort: 'createdAt' })
255 expect(body.total).to.equal(1)
256 301
257 const follows = body.data 302 {
258 expect(follows).to.be.an('array') 303 const { total, data } = await servers[0].videos.list()
259 expect(follows.length).to.equal(1) 304 expect(total).to.equal(1)
305 expect(data[0].name).to.equal('server2')
306 }
260 307
261 expect(follows[0].following.host).to.equal('localhost:' + servers[1].port) 308 {
262 }) 309 const { total, data } = await servers[1].videos.list()
310 expect(total).to.equal(1)
311 expect(data[0].name).to.equal('server2')
312 }
263 313
264 it('Should not have server 1 as follower on server 3 anymore', async function () { 314 {
265 const body = await followsCommands[2].getFollowers({ start: 0, count: 1, sort: 'createdAt' }) 315 const { total, data } = await servers[2].videos.list()
266 expect(body.total).to.equal(0) 316 expect(total).to.equal(1)
317 expect(data[0].name).to.equal('server3')
318 }
319 })
267 320
268 const follows = body.data 321 it('Should remove account follow', async function () {
269 expect(follows).to.be.an('array') 322 this.timeout(15000)
270 expect(follows.length).to.equal(0)
271 })
272 323
273 it('Should have the correct follows counts 2', async function () { 324 await servers[0].follows.unfollow({ target: 'root@' + servers[1].host })
274 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 })
275 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 })
276 325
277 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) 326 await waitJobs(servers)
278 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) 327 })
279 328
280 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 0 }) 329 it('Should have removed the account follow', async function () {
281 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 0, following: 0 }) 330 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
282 }) 331 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
283 332
284 it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () { 333 {
285 this.timeout(60000) 334 const { total, data } = await servers[0].follows.getFollowings()
335 expect(total).to.equal(0)
336 expect(data).to.have.lengthOf(0)
337 }
286 338
287 await servers[1].videos.upload({ attributes: { name: 'server2' } }) 339 {
288 await servers[2].videos.upload({ attributes: { name: 'server3' } }) 340 const { total, data } = await servers[0].videos.list()
341 expect(total).to.equal(0)
342 expect(data).to.have.lengthOf(0)
343 }
344 })
289 345
290 await waitJobs(servers) 346 it('Should follow a channel', async function () {
347 this.timeout(15000)
291 348
292 { 349 await servers[0].follows.follow({
293 const { total, data } = await servers[0].videos.list() 350 handles: [ 'root_channel@' + servers[1].host ]
294 expect(total).to.equal(1) 351 })
295 expect(data[0].name).to.equal('server2')
296 }
297 352
298 { 353 await waitJobs(servers)
299 const { total, data } = await servers[1].videos.list()
300 expect(total).to.equal(1)
301 expect(data[0].name).to.equal('server2')
302 }
303 354
304 { 355 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
305 const { total, data } = await servers[2].videos.list() 356 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
306 expect(total).to.equal(1) 357
307 expect(data[0].name).to.equal('server3') 358 {
308 } 359 const { total, data } = await servers[0].follows.getFollowings()
360 expect(total).to.equal(1)
361 expect(data).to.have.lengthOf(1)
362 }
363
364 {
365 const { total, data } = await servers[0].videos.list()
366 expect(total).to.equal(1)
367 expect(data).to.have.lengthOf(1)
368 }
369 })
309 }) 370 })
310 371
311 describe('Should propagate data on a new following', function () { 372 describe('Should propagate data on a new server follow', function () {
312 let video4: Video 373 let video4: VideoCreateResult
313 374
314 before(async function () { 375 before(async function () {
315 this.timeout(50000) 376 this.timeout(50000)
@@ -324,83 +385,64 @@ describe('Test follows', function () {
324 385
325 await servers[2].videos.upload({ attributes: { name: 'server3-2' } }) 386 await servers[2].videos.upload({ attributes: { name: 'server3-2' } })
326 await servers[2].videos.upload({ attributes: { name: 'server3-3' } }) 387 await servers[2].videos.upload({ attributes: { name: 'server3-3' } })
327 await servers[2].videos.upload({ attributes: video4Attributes }) 388 video4 = await servers[2].videos.upload({ attributes: video4Attributes })
328 await servers[2].videos.upload({ attributes: { name: 'server3-5' } }) 389 await servers[2].videos.upload({ attributes: { name: 'server3-5' } })
329 await servers[2].videos.upload({ attributes: { name: 'server3-6' } }) 390 await servers[2].videos.upload({ attributes: { name: 'server3-6' } })
330 391
331 { 392 {
332 const userAccessToken = await servers[2].users.generateUserAndToken('captain') 393 const userAccessToken = await servers[2].users.generateUserAndToken('captain')
333 394
334 const { data } = await servers[2].videos.list() 395 await servers[2].videos.rate({ id: video4.id, rating: 'like' })
335 video4 = data.find(v => v.name === 'server3-4') 396 await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' })
336 397 }
337 {
338 await servers[2].videos.rate({ id: video4.id, rating: 'like' })
339 await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' })
340 }
341
342 {
343 {
344 const text = 'my super first comment'
345 const created = await servers[2].comments.createThread({ videoId: video4.id, text })
346 const threadId = created.id
347
348 const text1 = 'my super answer to thread 1'
349 const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 })
350
351 const text2 = 'my super answer to answer of thread 1'
352 await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text2 })
353
354 const text3 = 'my second answer to thread 1'
355 await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text3 })
356 }
357 398
358 { 399 {
359 const text = 'will be deleted' 400 await servers[2].comments.createThread({ videoId: video4.id, text: 'my super first comment' })
360 const created = await servers[2].comments.createThread({ videoId: video4.id, text })
361 const threadId = created.id
362 401
363 const text1 = 'answer to deleted' 402 await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' })
364 await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 }) 403 await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' })
404 await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' })
405 }
365 406
366 const text2 = 'will also be deleted' 407 {
367 const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text2 }) 408 const { id: threadId } = await servers[2].comments.createThread({ videoId: video4.id, text: 'will be deleted' })
409 await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' })
368 410
369 const text3 = 'my second answer to deleted' 411 const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' })
370 await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text3 })
371 412
372 await servers[2].comments.delete({ videoId: video4.id, commentId: threadId }) 413 await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' })
373 await servers[2].comments.delete({ videoId: video4.id, commentId: childComment.id })
374 }
375 }
376 414
377 { 415 await servers[2].comments.delete({ videoId: video4.id, commentId: threadId })
378 await servers[2].captions.createVideoCaption({ 416 await servers[2].comments.delete({ videoId: video4.id, commentId: replyId })
379 language: 'ar',
380 videoId: video4.id,
381 fixture: 'subtitle-good2.vtt'
382 })
383 }
384 } 417 }
385 418
419 await servers[2].captions.createVideoCaption({
420 language: 'ar',
421 videoId: video4.id,
422 fixture: 'subtitle-good2.vtt'
423 })
424
386 await waitJobs(servers) 425 await waitJobs(servers)
387 426
388 // Server 1 follows server 3 427 // Server 1 follows server 3
389 await followsCommands[0].follow({ targets: [ servers[2].url ] }) 428 await servers[0].follows.follow({ hosts: [ servers[2].url ] })
390 429
391 await waitJobs(servers) 430 await waitJobs(servers)
392 }) 431 })
393 432
394 it('Should have the correct follows counts 3', async function () { 433 it('Should have the correct follows counts', async function () {
395 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 }) 434 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
396 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) 435 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
397 await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) 436 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
437 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
398 438
399 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) 439 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
400 await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) 440 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
441 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
442 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
401 443
402 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) 444 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
403 await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) 445 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
404 }) 446 })
405 447
406 it('Should have propagated videos', async function () { 448 it('Should have propagated videos', async function () {
@@ -426,7 +468,7 @@ describe('Test follows', function () {
426 support: 'my super support text', 468 support: 'my super support text',
427 account: { 469 account: {
428 name: 'root', 470 name: 'root',
429 host: 'localhost:' + servers[2].port 471 host: servers[2].host
430 }, 472 },
431 isLocal, 473 isLocal,
432 commentsEnabled: true, 474 commentsEnabled: true,
@@ -467,7 +509,7 @@ describe('Test follows', function () {
467 expect(comment.videoId).to.equal(video4.id) 509 expect(comment.videoId).to.equal(video4.id)
468 expect(comment.id).to.equal(comment.threadId) 510 expect(comment.id).to.equal(comment.threadId)
469 expect(comment.account.name).to.equal('root') 511 expect(comment.account.name).to.equal('root')
470 expect(comment.account.host).to.equal('localhost:' + servers[2].port) 512 expect(comment.account.host).to.equal(servers[2].host)
471 expect(comment.totalReplies).to.equal(3) 513 expect(comment.totalReplies).to.equal(3)
472 expect(dateIsValid(comment.createdAt as string)).to.be.true 514 expect(dateIsValid(comment.createdAt as string)).to.be.true
473 expect(dateIsValid(comment.updatedAt as string)).to.be.true 515 expect(dateIsValid(comment.updatedAt as string)).to.be.true
@@ -541,14 +583,39 @@ describe('Test follows', function () {
541 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { 583 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
542 this.timeout(5000) 584 this.timeout(5000)
543 585
544 await followsCommands[0].unfollow({ target: servers[2] }) 586 await servers[0].follows.unfollow({ target: servers[2] })
545 587
546 await waitJobs(servers) 588 await waitJobs(servers)
547 589
548 const { total } = await servers[0].videos.list() 590 const { total } = await servers[0].videos.list()
549 expect(total).to.equal(1) 591 expect(total).to.equal(1)
550 }) 592 })
593 })
594
595 describe('Should propagate data on a new channel follow', function () {
596
597 before(async function () {
598 this.timeout(60000)
551 599
600 await servers[2].videos.upload({ attributes: { name: 'server3-7' } })
601
602 await waitJobs(servers)
603
604 const video = await servers[0].videos.find({ name: 'server3-7' })
605 expect(video).to.not.exist
606 })
607
608 it('Should have propagated channel video', async function () {
609 this.timeout(60000)
610
611 await servers[0].follows.follow({ handles: [ 'root_channel@' + servers[2].host ] })
612
613 await waitJobs(servers)
614
615 const video = await servers[0].videos.find({ name: 'server3-7' })
616
617 expect(video).to.exist
618 })
552 }) 619 })
553 620
554 after(async function () { 621 after(async function () {
diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts
index 1f751c957..2f3950354 100644
--- a/server/tests/api/server/handle-down.ts
+++ b/server/tests/api/server/handle-down.ts
@@ -97,8 +97,8 @@ describe('Test handle downs', function () {
97 this.timeout(240000) 97 this.timeout(240000)
98 98
99 // Server 2 and 3 follow server 1 99 // Server 2 and 3 follow server 1
100 await servers[1].follows.follow({ targets: [ servers[0].url ] }) 100 await servers[1].follows.follow({ hosts: [ servers[0].url ] })
101 await servers[2].follows.follow({ targets: [ servers[0].url ] }) 101 await servers[2].follows.follow({ hosts: [ servers[0].url ] })
102 102
103 await waitJobs(servers) 103 await waitJobs(servers)
104 104
@@ -180,7 +180,7 @@ describe('Test handle downs', function () {
180 await servers[1].follows.unfollow({ target: servers[0] }) 180 await servers[1].follows.unfollow({ target: servers[0] })
181 await waitJobs(servers) 181 await waitJobs(servers)
182 182
183 await servers[1].follows.follow({ targets: [ servers[0].url ] }) 183 await servers[1].follows.follow({ hosts: [ servers[0].url ] })
184 184
185 await waitJobs(servers) 185 await waitJobs(servers)
186 186
diff --git a/server/tests/api/server/stats.ts b/server/tests/api/server/stats.ts
index 942602b70..5ec771429 100644
--- a/server/tests/api/server/stats.ts
+++ b/server/tests/api/server/stats.ts
@@ -43,7 +43,7 @@ describe('Test stats (excluding redundancy)', function () {
43 // Wait the video views repeatable job 43 // Wait the video views repeatable job
44 await wait(8000) 44 await wait(8000)
45 45
46 await servers[2].follows.follow({ targets: [ servers[0].url ] }) 46 await servers[2].follows.follow({ hosts: [ servers[0].url ] })
47 await waitJobs(servers) 47 await waitJobs(servers)
48 }) 48 })
49 49
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index 565b4bd77..77b99886d 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -224,7 +224,7 @@ describe('Test users subscriptions', function () {
224 it('Should have server 1 follow server 3 and display server 3 videos', async function () { 224 it('Should have server 1 follow server 3 and display server 3 videos', async function () {
225 this.timeout(60000) 225 this.timeout(60000)
226 226
227 await servers[0].follows.follow({ targets: [ servers[2].url ] }) 227 await servers[0].follows.follow({ hosts: [ servers[2].url ] })
228 228
229 await waitJobs(servers) 229 await waitJobs(servers)
230 230
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 066da88ee..1419ae820 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -103,7 +103,7 @@ describe('Test users', function () {
103 token = 'my_super_token' 103 token = 'my_super_token'
104 104
105 await server.follows.follow({ 105 await server.follows.follow({
106 targets: [ 'http://example.com' ], 106 hosts: [ 'http://example.com' ],
107 token, 107 token,
108 expectedStatus: HttpStatusCode.UNAUTHORIZED_401 108 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
109 }) 109 })
diff --git a/shared/extra-utils/server/follows-command.ts b/shared/extra-utils/server/follows-command.ts
index dce674ac5..2b889cf66 100644
--- a/shared/extra-utils/server/follows-command.ts
+++ b/shared/extra-utils/server/follows-command.ts
@@ -1,5 +1,5 @@
1import { pick } from 'lodash' 1import { pick } from 'lodash'
2import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList } from '@shared/models' 2import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models'
3import { AbstractCommand, OverrideCommandOptions } from '../shared' 3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4import { PeerTubeServer } from './server' 4import { PeerTubeServer } from './server'
5 5
@@ -29,13 +29,13 @@ export class FollowsCommand extends AbstractCommand {
29 } 29 }
30 30
31 getFollowings (options: OverrideCommandOptions & { 31 getFollowings (options: OverrideCommandOptions & {
32 start: number 32 start?: number
33 count: number 33 count?: number
34 sort: string 34 sort?: string
35 search?: string 35 search?: string
36 actorType?: ActivityPubActorType 36 actorType?: ActivityPubActorType
37 state?: FollowState 37 state?: FollowState
38 }) { 38 } = {}) {
39 const path = '/api/v1/server/following' 39 const path = '/api/v1/server/following'
40 40
41 const toPick = [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ] 41 const toPick = [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ]
@@ -52,26 +52,41 @@ export class FollowsCommand extends AbstractCommand {
52 } 52 }
53 53
54 follow (options: OverrideCommandOptions & { 54 follow (options: OverrideCommandOptions & {
55 targets: string[] 55 hosts?: string[]
56 handles?: string[]
56 }) { 57 }) {
57 const path = '/api/v1/server/following' 58 const path = '/api/v1/server/following'
58 59
59 const hosts = options.targets.map(f => f.replace(/^http:\/\//, '')) 60 const fields: ServerFollowCreate = {}
61
62 if (options.hosts) {
63 fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, ''))
64 }
65
66 if (options.handles) {
67 fields.handles = options.handles
68 }
60 69
61 return this.postBodyRequest({ 70 return this.postBodyRequest({
62 ...options, 71 ...options,
63 72
64 path, 73 path,
65 fields: { hosts }, 74 fields,
66 implicitToken: true, 75 implicitToken: true,
67 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 76 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
68 }) 77 })
69 } 78 }
70 79
71 async unfollow (options: OverrideCommandOptions & { 80 async unfollow (options: OverrideCommandOptions & {
72 target: PeerTubeServer 81 target: PeerTubeServer | string
73 }) { 82 }) {
74 const path = '/api/v1/server/following/' + options.target.host 83 const { target } = options
84
85 const handle = typeof target === 'string'
86 ? target
87 : target.host
88
89 const path = '/api/v1/server/following/' + handle
75 90
76 return this.deleteRequest({ 91 return this.deleteRequest({
77 ...options, 92 ...options,
diff --git a/shared/extra-utils/server/follows.ts b/shared/extra-utils/server/follows.ts
index 0188be1aa..698238f29 100644
--- a/shared/extra-utils/server/follows.ts
+++ b/shared/extra-utils/server/follows.ts
@@ -3,8 +3,8 @@ import { PeerTubeServer } from './server'
3 3
4async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { 4async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) {
5 await Promise.all([ 5 await Promise.all([
6 server1.follows.follow({ targets: [ server2.url ] }), 6 server1.follows.follow({ hosts: [ server2.url ] }),
7 server2.follows.follow({ targets: [ server1.url ] }) 7 server2.follows.follow({ hosts: [ server1.url ] })
8 ]) 8 ])
9 9
10 // Wait request propagation 10 // Wait request propagation
diff --git a/shared/extra-utils/users/accounts.ts b/shared/extra-utils/users/actors.ts
index 9fc1bcfc4..cfcc7d0a7 100644
--- a/shared/extra-utils/users/accounts.ts
+++ b/shared/extra-utils/users/actors.ts
@@ -4,22 +4,31 @@ import { expect } from 'chai'
4import { pathExists, readdir } from 'fs-extra' 4import { pathExists, readdir } from 'fs-extra'
5import { join } from 'path' 5import { join } from 'path'
6import { root } from '@server/helpers/core-utils' 6import { root } from '@server/helpers/core-utils'
7import { Account, VideoChannel } from '@shared/models'
7import { PeerTubeServer } from '../server' 8import { PeerTubeServer } from '../server'
8 9
9async function expectAccountFollows (options: { 10async function expectChannelsFollows (options: {
10 server: PeerTubeServer 11 server: PeerTubeServer
11 handle: string 12 handle: string
12 followers: number 13 followers: number
13 following: number 14 following: number
14}) { 15}) {
15 const { server, handle, followers, following } = options 16 const { server } = options
17 const { data } = await server.channels.list()
16 18
17 const body = await server.accounts.list() 19 return expectActorFollow({ ...options, data })
18 const account = body.data.find(a => a.name + '@' + a.host === handle) 20}
19 21
20 const message = `${handle} on ${server.url}` 22async function expectAccountFollows (options: {
21 expect(account.followersCount).to.equal(followers, message) 23 server: PeerTubeServer
22 expect(account.followingCount).to.equal(following, message) 24 handle: string
25 followers: number
26 following: number
27}) {
28 const { server } = options
29 const { data } = await server.accounts.list()
30
31 return expectActorFollow({ ...options, data })
23} 32}
24 33
25async function checkActorFilesWereRemoved (filename: string, serverNumber: number) { 34async function checkActorFilesWereRemoved (filename: string, serverNumber: number) {
@@ -40,5 +49,25 @@ async function checkActorFilesWereRemoved (filename: string, serverNumber: numbe
40 49
41export { 50export {
42 expectAccountFollows, 51 expectAccountFollows,
52 expectChannelsFollows,
43 checkActorFilesWereRemoved 53 checkActorFilesWereRemoved
44} 54}
55
56// ---------------------------------------------------------------------------
57
58function expectActorFollow (options: {
59 server: PeerTubeServer
60 data: (Account | VideoChannel)[]
61 handle: string
62 followers: number
63 following: number
64}) {
65 const { server, data, handle, followers, following } = options
66
67 const actor = data.find(a => a.name + '@' + a.host === handle)
68 const message = `${handle} on ${server.url}`
69
70 expect(actor, message).to.exist
71 expect(actor.followersCount).to.equal(followers, message)
72 expect(actor.followingCount).to.equal(following, message)
73}
diff --git a/shared/extra-utils/users/index.ts b/shared/extra-utils/users/index.ts
index fbb454e8f..460a06f70 100644
--- a/shared/extra-utils/users/index.ts
+++ b/shared/extra-utils/users/index.ts
@@ -1,5 +1,5 @@
1export * from './accounts-command' 1export * from './accounts-command'
2export * from './accounts' 2export * from './actors'
3export * from './blocklist-command' 3export * from './blocklist-command'
4export * from './login' 4export * from './login'
5export * from './login-command' 5export * from './login-command'
diff --git a/shared/extra-utils/videos/comments-command.ts b/shared/extra-utils/videos/comments-command.ts
index dd14e4b64..5034c57ad 100644
--- a/shared/extra-utils/videos/comments-command.ts
+++ b/shared/extra-utils/videos/comments-command.ts
@@ -5,6 +5,10 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared'
5 5
6export class CommentsCommand extends AbstractCommand { 6export class CommentsCommand extends AbstractCommand {
7 7
8 private lastVideoId: number | string
9 private lastThreadId: number
10 private lastReplyId: number
11
8 listForAdmin (options: OverrideCommandOptions & { 12 listForAdmin (options: OverrideCommandOptions & {
9 start?: number 13 start?: number
10 count?: number 14 count?: number
@@ -80,6 +84,9 @@ export class CommentsCommand extends AbstractCommand {
80 defaultExpectedStatus: HttpStatusCode.OK_200 84 defaultExpectedStatus: HttpStatusCode.OK_200
81 })) 85 }))
82 86
87 this.lastThreadId = body.comment.id
88 this.lastVideoId = videoId
89
83 return body.comment 90 return body.comment
84 } 91 }
85 92
@@ -100,9 +107,23 @@ export class CommentsCommand extends AbstractCommand {
100 defaultExpectedStatus: HttpStatusCode.OK_200 107 defaultExpectedStatus: HttpStatusCode.OK_200
101 })) 108 }))
102 109
110 this.lastReplyId = body.comment.id
111
103 return body.comment 112 return body.comment
104 } 113 }
105 114
115 async addReplyToLastReply (options: OverrideCommandOptions & {
116 text: string
117 }) {
118 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId })
119 }
120
121 async addReplyToLastThread (options: OverrideCommandOptions & {
122 text: string
123 }) {
124 return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId })
125 }
126
106 async findCommentId (options: OverrideCommandOptions & { 127 async findCommentId (options: OverrideCommandOptions & {
107 videoId: number | string 128 videoId: number | string
108 text: string 129 text: string
diff --git a/shared/extra-utils/videos/videos-command.ts b/shared/extra-utils/videos/videos-command.ts
index 40cc4dc28..98465e8f6 100644
--- a/shared/extra-utils/videos/videos-command.ts
+++ b/shared/extra-utils/videos/videos-command.ts
@@ -267,6 +267,16 @@ export class VideosCommand extends AbstractCommand {
267 267
268 // --------------------------------------------------------------------------- 268 // ---------------------------------------------------------------------------
269 269
270 async find (options: OverrideCommandOptions & {
271 name: string
272 }) {
273 const { data } = await this.list(options)
274
275 return data.find(v => v.name === options.name)
276 }
277
278 // ---------------------------------------------------------------------------
279
270 update (options: OverrideCommandOptions & { 280 update (options: OverrideCommandOptions & {
271 id: number | string 281 id: number | string
272 attributes?: VideoEdit 282 attributes?: VideoEdit
diff --git a/shared/models/server/index.ts b/shared/models/server/index.ts
index 06bf5c599..0f7646c7a 100644
--- a/shared/models/server/index.ts
+++ b/shared/models/server/index.ts
@@ -10,4 +10,5 @@ export * from './peertube-problem-document.model'
10export * from './server-config.model' 10export * from './server-config.model'
11export * from './server-debug.model' 11export * from './server-debug.model'
12export * from './server-error-code.enum' 12export * from './server-error-code.enum'
13export * from './server-follow-create.model'
13export * from './server-stats.model' 14export * 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
index 000000000..3f90c7d6f
--- /dev/null
+++ b/shared/models/server/server-follow-create.model.ts
@@ -0,0 +1,4 @@
1export interface ServerFollowCreate {
2 hosts?: string[]
3 handles?: string[]
4}
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 99a725ead..76e78fe53 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -716,7 +716,7 @@ paths:
716 - admin 716 - admin
717 tags: 717 tags:
718 - Instance Follows 718 - Instance Follows
719 summary: Follow a list of servers 719 summary: Follow a list of actors (PeerTube instance, channel or account)
720 responses: 720 responses:
721 '204': 721 '204':
722 description: successful operation 722 description: successful operation
@@ -734,28 +734,32 @@ paths:
734 type: string 734 type: string
735 format: hostname 735 format: hostname
736 uniqueItems: true 736 uniqueItems: true
737 handles:
738 type: array
739 items:
740 type: string
741 uniqueItems: true
737 742
738 '/server/following/{host}': 743 '/server/following/{hostOrHandle}':
739 delete: 744 delete:
740 summary: Unfollow a server 745 summary: Unfollow an actor (PeerTube instance, channel or account)
741 security: 746 security:
742 - OAuth2: 747 - OAuth2:
743 - admin 748 - admin
744 tags: 749 tags:
745 - Instance Follows 750 - Instance Follows
746 parameters: 751 parameters:
747 - name: host 752 - name: hostOrHandle
748 in: path 753 in: path
749 required: true 754 required: true
750 description: The host to unfollow 755 description: The hostOrHandle to unfollow
751 schema: 756 schema:
752 type: string 757 type: string
753 format: hostname
754 responses: 758 responses:
755 '204': 759 '204':
756 description: successful operation 760 description: successful operation
757 '404': 761 '404':
758 description: host not found 762 description: host or handle not found
759 763
760 /users: 764 /users:
761 post: 765 post: