From 4d029ef8ec3d5274eeaa3ee6d808eb7035e7faef Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 20 Jul 2021 14:15:15 +0200 Subject: [PATCH] Add ability for instances to follow any actor --- client/src/app/+admin/admin.component.ts | 4 +- client/src/app/+admin/admin.module.ts | 3 +- .../followers-list.component.html | 4 +- .../follow-modal.component.html | 42 ++ .../follow-modal.component.scss | 3 + .../following-list/follow-modal.component.ts | 69 ++ .../following-list.component.html | 21 +- .../following-list.component.ts | 22 +- .../+admin/follows/following-list/index.ts | 1 + .../src/app/+admin/follows/follows.routes.ts | 4 +- .../batch-domains-validators.ts | 60 -- .../shared/form-validators/host-validators.ts | 105 ++++ client/src/app/shared/form-validators/host.ts | 8 - .../src/app/shared/form-validators/index.ts | 1 - .../instance-follow.service.ts | 13 +- .../batch-domains-modal.component.html | 14 +- .../batch-domains-modal.component.ts | 8 +- server/controllers/api/server/follows.ts | 21 +- server/helpers/custom-validators/follows.ts | 20 +- server/helpers/custom-validators/servers.ts | 1 - server/lib/activitypub/crawl.ts | 3 +- server/lib/activitypub/follow.ts | 17 +- server/middlewares/validators/follows.ts | 37 +- server/models/actor/actor-follow.ts | 26 +- .../video/sql/videos-id-list-query-builder.ts | 6 +- server/tests/api/check-params/follows.ts | 36 +- server/tests/api/moderation/blocklist.ts | 4 +- .../notifications/moderation-notifications.ts | 4 +- .../api/redundancy/redundancy-constraints.ts | 4 +- server/tests/api/server/auto-follows.ts | 2 +- server/tests/api/server/follows-moderation.ts | 10 +- server/tests/api/server/follows.ts | 595 ++++++++++-------- server/tests/api/server/handle-down.ts | 6 +- server/tests/api/server/stats.ts | 2 +- server/tests/api/users/user-subscriptions.ts | 2 +- server/tests/api/users/users.ts | 2 +- shared/extra-utils/server/follows-command.ts | 35 +- shared/extra-utils/server/follows.ts | 4 +- .../users/{accounts.ts => actors.ts} | 43 +- shared/extra-utils/users/index.ts | 2 +- shared/extra-utils/videos/comments-command.ts | 21 + shared/extra-utils/videos/videos-command.ts | 10 + shared/models/server/index.ts | 1 + .../server/server-follow-create.model.ts | 4 + support/doc/api/openapi.yaml | 18 +- 45 files changed, 837 insertions(+), 481 deletions(-) create mode 100644 client/src/app/+admin/follows/following-list/follow-modal.component.html create mode 100644 client/src/app/+admin/follows/following-list/follow-modal.component.scss create mode 100644 client/src/app/+admin/follows/following-list/follow-modal.component.ts delete mode 100644 client/src/app/shared/form-validators/batch-domains-validators.ts create mode 100644 client/src/app/shared/form-validators/host-validators.ts delete mode 100644 client/src/app/shared/form-validators/host.ts rename shared/extra-utils/users/{accounts.ts => actors.ts} (50%) create mode 100644 shared/models/server/server-follow-create.model.ts 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 { label: $localize`Federation`, children: [ { - label: $localize`Instances you follow`, + label: $localize`Following`, routerLink: '/admin/follows/following-list', iconName: 'following' }, { - label: $localize`Instances following you`, + label: $localize`Followers`, routerLink: '/admin/follows/followers-list', iconName: 'follower' }, 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 { EditVODTranscodingComponent } from './config' import { ConfigService } from './config/shared/config.service' -import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows' +import { FollowersListComponent, FollowModalComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows' import { FollowingListComponent } from './follows/following-list/following-list.component' import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' @@ -68,6 +68,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom FollowsComponent, FollowersListComponent, FollowingListComponent, + FollowModalComponent, RedundancyCheckboxComponent, VideoRedundanciesListComponent, VideoRedundancyInformationComponent, 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 @@

- Instances following you + Followers of your instance

Actions - Follower handle + Follower State Score Created 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 @@ + + + + + + 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 @@ +textarea { + height: 200px; +} diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts new file mode 100644 index 000000000..dc6909200 --- /dev/null +++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts @@ -0,0 +1,69 @@ +import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { Notifier } from '@app/core' +import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { InstanceFollowService } from '@app/shared/shared-instance' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' + +@Component({ + selector: 'my-follow-modal', + templateUrl: './follow-modal.component.html', + styleUrls: [ './follow-modal.component.scss' ] +}) +export class FollowModalComponent extends FormReactive implements OnInit { + @ViewChild('modal', { static: true }) modal: NgbModal + + @Output() newFollow = new EventEmitter() + + placeholder = 'example.com\nchocobozzz@example.com\nchocobozzz_channel@example.com' + + private openedModal: NgbModalRef + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private followService: InstanceFollowService, + private notifier: Notifier + ) { + super() + } + + ngOnInit () { + this.buildForm({ + hostsOrHandles: UNIQUE_HOSTS_OR_HANDLE_VALIDATOR + }) + } + + openModal () { + this.openedModal = this.modalService.open(this.modal, { centered: true }) + } + + hide () { + this.openedModal.close() + } + + submit () { + this.addFollowing() + + this.form.reset() + this.hide() + } + + httpEnabled () { + return window.location.protocol === 'https:' + } + + private async addFollowing () { + const hostsOrHandles = splitAndGetNotEmpty(this.form.value['hostsOrHandles']) + + this.followService.follow(hostsOrHandles).subscribe( + () => { + this.notifier.success($localize`Follow request(s) sent!`) + this.newFollow.emit() + }, + + err => this.notifier.error(err.message) + ) + } +} 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 @@

- Instances you follow + Your instance subscriptions

@@ -28,7 +28,7 @@ Action - Host + Following State Created Redundancy allowed @@ -41,8 +41,8 @@ - - {{ follow.following.host }} + + {{ follow.following.name + '@' + follow.following.host }} @@ -57,6 +57,7 @@ {{ follow.createdAt | date: 'short' }} @@ -75,10 +76,4 @@ - - -
- It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers. -
-
-
+ 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' import { InstanceFollowService } from '@app/shared/shared-instance' import { BatchDomainsModalComponent } from '@app/shared/shared-moderation' import { ActorFollow } from '@shared/models' +import { FollowModalComponent } from './follow-modal.component' @Component({ templateUrl: './following-list.component.html', styleUrls: [ '../follows.component.scss', './following-list.component.scss' ] }) export class FollowingListComponent extends RestTable implements OnInit { - @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent + @ViewChild('followModal') followModal: FollowModalComponent following: ActorFollow[] = [] totalRecords = 0 @@ -33,23 +34,12 @@ export class FollowingListComponent extends RestTable implements OnInit { return 'FollowingListComponent' } - addDomainsToFollow () { - this.batchDomainsModal.openModal() + openFollowModal () { + this.followModal.openModal() } - httpEnabled () { - return window.location.protocol === 'https:' - } - - async addFollowing (hosts: string[]) { - this.followService.follow(hosts).subscribe( - () => { - this.notifier.success($localize`Follow request(s) sent!`) - this.reloadData() - }, - - err => this.notifier.error(err.message) - ) + isInstanceFollowing (follow: ActorFollow) { + return follow.following.name === 'peertube' } async removeFollowing (follow: ActorFollow) { 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 @@ +export * from './follow-modal.component' export * 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 = [ component: FollowingListComponent, data: { meta: { - title: $localize`Following list` + title: $localize`Following` } } }, @@ -34,7 +34,7 @@ export const FollowsRoutes: Routes = [ component: FollowersListComponent, data: { meta: { - title: $localize`Followers list` + title: $localize`Followers` } } }, diff --git a/client/src/app/shared/form-validators/batch-domains-validators.ts b/client/src/app/shared/form-validators/batch-domains-validators.ts deleted file mode 100644 index 423d1337f..000000000 --- a/client/src/app/shared/form-validators/batch-domains-validators.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AbstractControl, FormControl, ValidatorFn, Validators } from '@angular/forms' -import { BuildFormValidator } from './form-validator.model' -import { validateHost } from './host' - -export function getNotEmptyHosts (hosts: string) { - return hosts - .split('\n') - .filter((host: string) => host && host.length !== 0) // Eject empty hosts -} - -const validDomains: ValidatorFn = (control: FormControl) => { - if (!control.value) return null - - const newHostsErrors = [] - const hosts = getNotEmptyHosts(control.value) - - for (const host of hosts) { - if (validateHost(host) === false) { - newHostsErrors.push($localize`${host} is not valid`) - } - } - - /* Is not valid. */ - if (newHostsErrors.length !== 0) { - return { - 'validDomains': { - reason: 'invalid', - value: newHostsErrors.join('. ') + '.' - } - } - } - - /* Is valid. */ - return null -} - -const isHostsUnique: ValidatorFn = (control: AbstractControl) => { - if (!control.value) return null - - const hosts = getNotEmptyHosts(control.value) - - if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { - return null - } else { - return { - 'uniqueDomains': { - reason: 'invalid' - } - } - } -} - -export const DOMAINS_VALIDATOR: BuildFormValidator = { - VALIDATORS: [Validators.required, validDomains, isHostsUnique], - MESSAGES: { - 'required': $localize`Domain is required.`, - 'validDomains': $localize`Domains entered are invalid.`, - 'uniqueDomains': $localize`Domains entered contain duplicates.` - } -} diff --git a/client/src/app/shared/form-validators/host-validators.ts b/client/src/app/shared/form-validators/host-validators.ts new file mode 100644 index 000000000..d750113ef --- /dev/null +++ b/client/src/app/shared/form-validators/host-validators.ts @@ -0,0 +1,105 @@ +import { AbstractControl, ValidatorFn, Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.model' + +function validateHost (value: string) { + // Thanks to http://stackoverflow.com/a/106223 + const HOST_REGEXP = new RegExp( + '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' + ) + + return HOST_REGEXP.test(value) +} + +function validateHandle (value: string) { + if (!value) return false + + return value.includes('@') +} + +const validHosts: ValidatorFn = (control: AbstractControl) => { + if (!control.value) return null + + const errors = [] + const hosts = splitAndGetNotEmpty(control.value) + + for (const host of hosts) { + if (validateHost(host) === false) { + errors.push($localize`${host} is not valid`) + } + } + + // valid + if (errors.length === 0) return null + + return { + 'validHosts': { + reason: 'invalid', + value: errors.join('. ') + '.' + } + } +} + +const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => { + if (!control.value) return null + + const errors = [] + const lines = splitAndGetNotEmpty(control.value) + + for (const line of lines) { + if (validateHost(line) === false && validateHandle(line) === false) { + errors.push($localize`${line} is not valid`) + } + } + + // valid + if (errors.length === 0) return null + + return { + 'validHostsOrHandles': { + reason: 'invalid', + value: errors.join('. ') + '.' + } + } +} + +// --------------------------------------------------------------------------- + +export function splitAndGetNotEmpty (value: string) { + return value + .split('\n') + .filter(line => line && line.length !== 0) // Eject empty hosts +} + +export const unique: ValidatorFn = (control: AbstractControl) => { + if (!control.value) return null + + const hosts = splitAndGetNotEmpty(control.value) + + if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { + return null + } + + return { + 'unique': { + reason: 'invalid' + } + } +} + +export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.required, validHosts, unique ], + MESSAGES: { + 'required': $localize`Domain is required.`, + 'validHosts': $localize`Hosts entered are invalid.`, + 'unique': $localize`Hosts entered contain duplicates.` + } +} + +export const UNIQUE_HOSTS_OR_HANDLE_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.required, validHostsOrHandles, unique ], + MESSAGES: { + 'required': $localize`Domain is required.`, + 'validHostsOrHandles': $localize`Hosts or handles are invalid.`, + 'unique': $localize`Hosts or handles contain duplicates.` + } +} diff --git a/client/src/app/shared/form-validators/host.ts b/client/src/app/shared/form-validators/host.ts deleted file mode 100644 index c18a35f9b..000000000 --- a/client/src/app/shared/form-validators/host.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function validateHost (value: string) { - // Thanks to http://stackoverflow.com/a/106223 - const HOST_REGEXP = new RegExp( - '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' - ) - - return HOST_REGEXP.test(value) -} 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 @@ export * from './form-validator.model' -export * from './host' // Don't re export const variables because webpack 4 cannot do tree shaking with them // export * from './abuse-validators' 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' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' -import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/models' +import { ActivityPubActorType, ActorFollow, FollowState, ResultList, ServerFollowCreate } from '@shared/models' import { environment } from '../../../environments/environment' @Injectable() @@ -64,9 +64,10 @@ export class InstanceFollowService { ) } - follow (notEmptyHosts: string[]) { - const body = { - hosts: notEmptyHosts + follow (hostsOrHandles: string[]) { + const body: ServerFollowCreate = { + handles: hostsOrHandles.filter(v => v.includes('@')), + hosts: hostsOrHandles.filter(v => !v.includes('@')) } return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body) @@ -77,7 +78,9 @@ export class InstanceFollowService { } unfollow (follow: ActorFollow) { - return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host) + const handle = follow.following.name + '@' + follow.following.host + + return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + handle) .pipe( map(this.restExtractor.extractDataBool), catchError(res => this.restExtractor.handleError(res)) 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 @@ @@ -11,15 +11,15 @@ -
- {{ formErrors.domains }} +
+ {{ formErrors.hosts }} -
- {{ form.controls['domains'].errors.validDomains.value }} +
+ {{ form.controls['hosts'].errors.validHosts.value }}
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 import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' -import { DOMAINS_VALIDATOR, getNotEmptyHosts } from '../form-validators/batch-domains-validators' +import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators' @Component({ selector: 'my-batch-domains-modal', @@ -28,7 +28,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit { if (!this.action) this.action = $localize`Process domains` this.buildForm({ - domains: DOMAINS_VALIDATOR + hosts: UNIQUE_HOSTS_VALIDATOR }) } @@ -41,9 +41,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit { } submit () { - this.domains.emit( - getNotEmptyHosts(this.form.controls['domains'].value) - ) + this.domains.emit(splitAndGetNotEmpty(this.form.controls['hosts'].value)) this.form.reset() this.hide() } 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 { removeFollowingValidator } from '../../../middlewares/validators' import { ActorFollowModel } from '../../../models/actor/actor-follow' +import { ServerFollowCreate } from '@shared/models' const serverFollowsRouter = express.Router() serverFollowsRouter.get('/following', @@ -45,10 +46,10 @@ serverFollowsRouter.post('/following', ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), followValidator, setBodyHostsPort, - asyncMiddleware(followInstance) + asyncMiddleware(addFollow) ) -serverFollowsRouter.delete('/following/:host', +serverFollowsRouter.delete('/following/:hostOrHandle', authenticate, ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), asyncMiddleware(removeFollowingValidator), @@ -125,8 +126,8 @@ async function listFollowers (req: express.Request, res: express.Response) { return res.json(getFormattedObjects(resultList.data, resultList.total)) } -async function followInstance (req: express.Request, res: express.Response) { - const hosts = req.body.hosts as string[] +async function addFollow (req: express.Request, res: express.Response) { + const { hosts, handles } = req.body as ServerFollowCreate const follower = await getServerActor() for (const host of hosts) { @@ -139,6 +140,18 @@ async function followInstance (req: express.Request, res: express.Response) { JobQueue.Instance.createJob({ type: 'activitypub-follow', payload }) } + for (const handle of handles) { + const [ name, host ] = handle.split('@') + + const payload = { + host, + name, + followerActorId: follower.id + } + + JobQueue.Instance.createJob({ type: 'activitypub-follow', payload }) + } + return res.status(HttpStatusCode.NO_CONTENT_204).end() } 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 @@ -import { exists } from './misc' +import { exists, isArray } from './misc' import { FollowState } from '@shared/models' function isFollowStateValid (value: FollowState) { @@ -7,8 +7,24 @@ function isFollowStateValid (value: FollowState) { return value === 'pending' || value === 'accepted' } +function isRemoteHandleValid (value: string) { + if (!exists(value)) return false + if (typeof value !== 'string') return false + + return value.includes('@') +} + +function isEachUniqueHandleValid (handles: string[]) { + return isArray(handles) && + handles.every(handle => { + return isRemoteHandleValid(handle) && handles.indexOf(handle) === handles.lastIndexOf(handle) + }) +} + // --------------------------------------------------------------------------- export { - isFollowStateValid + isFollowStateValid, + isRemoteHandleValid, + isEachUniqueHandleValid } 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) { function isEachUniqueHostValid (hosts: string[]) { return isArray(hosts) && - hosts.length !== 0 && hosts.every(host => { return isHostValid(host) && hosts.indexOf(host) === hosts.lastIndexOf(host) }) 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 @@ +import { retryTransactionWrapper } from '@server/helpers/database-utils' import * as Bluebird from 'bluebird' import { URL } from 'url' import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' @@ -51,7 +52,7 @@ async function crawlCollectionPage (argUrl: string, handler: HandlerFunction } } - if (cleaner) await cleaner(startDate) + if (cleaner) await retryTransactionWrapper(cleaner, startDate) } export { 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 } } +// If we only have an host, use a default account handle +function getRemoteNameAndHost (handleOrHost: string) { + let name = SERVER_ACTOR_NAME + let host = handleOrHost + + const splitted = handleOrHost.split('@') + if (splitted.length === 2) { + name = splitted[0] + host = splitted[1] + } + + return { name, host } +} + export { - autoFollowBackIfNeeded + autoFollowBackIfNeeded, + getRemoteNameAndHost } 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 @@ import * as express from 'express' import { body, param, query } from 'express-validator' -import { isFollowStateValid } from '@server/helpers/custom-validators/follows' +import { isEachUniqueHandleValid, isFollowStateValid, isRemoteHandleValid } from '@server/helpers/custom-validators/follows' import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors' +import { getRemoteNameAndHost } from '@server/lib/activitypub/follow' import { getServerActor } from '@server/models/application/application' import { MActorFollowActorsDefault } from '@server/types/models' import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' @@ -9,10 +10,11 @@ import { isTestInstance } from '../../helpers/core-utils' import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' import { logger } from '../../helpers/logger' -import { SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' +import { WEBSERVER } from '../../initializers/constants' import { ActorModel } from '../../models/actor/actor' import { ActorFollowModel } from '../../models/actor/actor-follow' import { areValidationErrors } from './shared' +import { ServerFollowCreate } from '@shared/models' const listFollowsValidator = [ query('state') @@ -30,29 +32,46 @@ const listFollowsValidator = [ ] const followValidator = [ - body('hosts').custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'), + body('hosts') + .toArray() + .custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'), + + body('handles') + .toArray() + .custom(isEachUniqueHandleValid).withMessage('Should have an array of handles'), (req: express.Request, res: express.Response, next: express.NextFunction) => { - // Force https if the administrator wants to make friends + // Force https if the administrator wants to follow remote actors if (isTestInstance() === false && WEBSERVER.SCHEME === 'http') { return res .status(HttpStatusCode.INTERNAL_SERVER_ERROR_500) .json({ error: 'Cannot follow on a non HTTPS web server.' }) - .end() } logger.debug('Checking follow parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return + const body: ServerFollowCreate = req.body + if (body.hosts.length === 0 && body.handles.length === 0) { + + return res + .status(HttpStatusCode.BAD_REQUEST_400) + .json({ + error: 'You must provide at least one handle or one host.' + }) + } + return next() } ] const removeFollowingValidator = [ - param('host').custom(isHostValid).withMessage('Should have a valid host'), + param('hostOrHandle') + .custom(value => isHostValid(value) || isRemoteHandleValid(value)) + .withMessage('Should have a valid host/handle'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking unfollowing parameters', { parameters: req.params }) @@ -60,12 +79,14 @@ const removeFollowingValidator = [ if (areValidationErrors(req, res)) return const serverActor = await getServerActor() - const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host) + + const { name, host } = getRemoteNameAndHost(req.params.hostOrHandle) + const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, name, host) if (!follow) { return res.fail({ status: HttpStatusCode.NOT_FOUND_404, - message: `Following ${req.params.host} not found.` + message: `Follow ${req.params.hostOrHandle} not found.` }) } 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 s.follows) // Get the access tokens await setAccessTokensToServers(servers) }) - it('Should not have followers', async function () { - for (const server of servers) { - const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }) - expect(body.total).to.equal(0) - - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(0) - } - }) + describe('Data propagation after follow', function () { - it('Should not have following', async function () { - for (const server of servers) { - const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) - expect(body.total).to.equal(0) + it('Should not have followers/followings', async function () { + for (const server of servers) { + const bodies = await Promise.all([ + server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }), + server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) + ]) - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(0) - } - }) + for (const body of bodies) { + expect(body.total).to.equal(0) - it('Should have server 1 following server 2 and 3', async function () { - this.timeout(30000) + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + } + } + }) - await followsCommands[0].follow({ targets: [ servers[1].url, servers[2].url ] }) + it('Should have server 1 following root account of server 2 and server 3', async function () { + this.timeout(30000) - await waitJobs(servers) - }) + await servers[0].follows.follow({ + hosts: [ servers[2].url ], + handles: [ 'root@' + servers[1].host ] + }) - it('Should have 2 followings on server 1', async function () { - const body = await followsCommands[0].getFollowings({ start: 0, count: 1, sort: 'createdAt' }) - expect(body.total).to.equal(2) + await waitJobs(servers) + }) - let follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(1) + it('Should have 2 followings on server 1', async function () { + const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(2) - const body2 = await followsCommands[0].getFollowings({ start: 1, count: 1, sort: 'createdAt' }) - follows = follows.concat(body2.data) + let follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) - const server2Follow = follows.find(f => f.following.host === 'localhost:' + servers[1].port) - const server3Follow = follows.find(f => f.following.host === 'localhost:' + servers[2].port) + const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' }) + follows = follows.concat(body2.data) - expect(server2Follow).to.not.be.undefined - expect(server3Follow).to.not.be.undefined - expect(server2Follow.state).to.equal('accepted') - expect(server3Follow.state).to.equal('accepted') - }) + const server2Follow = follows.find(f => f.following.host === servers[1].host) + const server3Follow = follows.find(f => f.following.host === servers[2].host) - it('Should search/filter followings on server 1', async function () { - const sort = 'createdAt' - const start = 0 - const count = 1 + expect(server2Follow).to.not.be.undefined + expect(server2Follow.following.name).to.equal('root') + expect(server2Follow.state).to.equal('accepted') - { - const search = ':' + servers[1].port + expect(server3Follow).to.not.be.undefined + expect(server3Follow.following.name).to.equal('peertube') + expect(server3Follow.state).to.equal('accepted') + }) - { - const body = await followsCommands[0].getFollowings({ start, count, sort, search }) - expect(body.total).to.equal(1) + it('Should have 0 followings on server 2 and 3', async function () { + for (const server of [ servers[1], servers[2] ]) { + const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(0) const follows = body.data - expect(follows.length).to.equal(1) - expect(follows[0].following.host).to.equal('localhost:' + servers[1].port) + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) } + }) - { - const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted' }) - expect(body.total).to.equal(1) - expect(body.data).to.have.lengthOf(1) + it('Should have 1 followers on server 3', async function () { + const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) + expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port) + }) + + it('Should have 0 followers on server 1 and 2', async function () { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }) + expect(body.total).to.equal(0) + + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) } + }) + + it('Should search/filter followings on server 1', async function () { + const sort = 'createdAt' + const start = 0 + const count = 1 { - const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) - expect(body.total).to.equal(0) - expect(body.data).to.have.lengthOf(0) + const search = ':' + servers[1].port + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search }) + expect(body.total).to.equal(1) + + const follows = body.data + expect(follows).to.have.lengthOf(1) + expect(follows[0].following.host).to.equal(servers[1].host) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } + + { + const body = await servers[0].follows.getFollowings({ + start, + count, + sort, + search, + state: 'accepted', + actorType: 'Application' + }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + + { + const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } } { - const body = await followsCommands[0].getFollowings({ - start, - count, - sort, - search, - state: 'accepted', - actorType: 'Application' - }) + const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' }) expect(body.total).to.equal(1) expect(body.data).to.have.lengthOf(1) } { - const body = await followsCommands[0].getFollowings({ start, count, sort, search, state: 'pending' }) + const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' }) expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) } - } + }) - { - const body = await followsCommands[0].getFollowings({ start, count, sort, search: 'bla' }) - expect(body.total).to.equal(0) + it('Should search/filter followers on server 2', async function () { + const start = 0 + const count = 5 + const sort = 'createdAt' - expect(body.data.length).to.equal(0) - } - }) + { + const search = servers[0].port + '' - it('Should have 0 followings on server 2 and 3', async function () { - for (const server of [ servers[1], servers[2] ]) { - const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) - expect(body.total).to.equal(0) + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search }) + expect(body.total).to.equal(1) - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(0) - } - }) + const follows = body.data + expect(follows).to.have.lengthOf(1) + expect(follows[0].following.host).to.equal(servers[2].host) + } - it('Should have 1 followers on server 2 and 3', async function () { - for (const server of [ servers[1], servers[2] ]) { - const body = await server.follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) - expect(body.total).to.equal(1) + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(1) - expect(follows[0].follower.host).to.equal('localhost:' + servers[0].port) - } - }) + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } - it('Should search/filter followers on server 2', async function () { - const start = 0 - const count = 5 - const sort = 'createdAt' + { + const body = await servers[2].follows.getFollowers({ + start, + count, + sort, + search, + state: 'accepted', + actorType: 'Application' + }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + } - { - const search = servers[0].port + '' + { + const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } { - const body = await followsCommands[2].getFollowers({ start, count, sort, search }) - expect(body.total).to.equal(1) + const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' }) + expect(body.total).to.equal(0) const follows = body.data - expect(follows.length).to.equal(1) - expect(follows[0].following.host).to.equal('localhost:' + servers[2].port) + expect(follows).to.have.lengthOf(0) } + }) - { - const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted' }) - expect(body.total).to.equal(1) - expect(body.data).to.have.lengthOf(1) - } + it('Should have the correct follows counts', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) - { - const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) - expect(body.total).to.equal(0) - expect(body.data).to.have.lengthOf(0) - } + // Server 2 and 3 does not know server 1 follow another server (there was not a refresh) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) - { - const body = await followsCommands[2].getFollowers({ - start, - count, - sort, - search, - state: 'accepted', - actorType: 'Application' - }) - expect(body.total).to.equal(1) - expect(body.data).to.have.lengthOf(1) - } + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) + }) - { - const body = await followsCommands[2].getFollowers({ start, count, sort, search, state: 'pending' }) - expect(body.total).to.equal(0) - expect(body.data).to.have.lengthOf(0) - } - } + it('Should unfollow server 3 on server 1', async function () { + this.timeout(15000) - { - const body = await followsCommands[2].getFollowers({ start, count, sort, search: 'bla' }) - expect(body.total).to.equal(0) + await servers[0].follows.unfollow({ target: servers[2] }) + + await waitJobs(servers) + }) + + it('Should not follow server 3 on server 1 anymore', async function () { + const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(1) const follows = body.data - expect(follows.length).to.equal(0) - } - }) + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(1) - it('Should have 0 followers on server 1', async function () { - const body = await followsCommands[0].getFollowers({ start: 0, count: 5, sort: 'createdAt' }) - expect(body.total).to.equal(0) + expect(follows[0].following.host).to.equal(servers[1].host) + }) - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(0) - }) + it('Should not have server 1 as follower on server 3 anymore', async function () { + const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) + expect(body.total).to.equal(0) - it('Should have the correct follows counts', async function () { - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 }) - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) + const follows = body.data + expect(follows).to.be.an('array') + expect(follows).to.have.lengthOf(0) + }) - // Server 2 and 3 does not know server 1 follow another server (there was not a refresh) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) + it('Should have the correct follows counts after the unfollow', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) - }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) - it('Should unfollow server 3 on server 1', async function () { - this.timeout(5000) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 }) + }) - await followsCommands[0].unfollow({ target: servers[2] }) + it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () { + this.timeout(60000) - await waitJobs(servers) - }) + await servers[1].videos.upload({ attributes: { name: 'server2' } }) + await servers[2].videos.upload({ attributes: { name: 'server3' } }) - it('Should not follow server 3 on server 1 anymore', async function () { - const body = await followsCommands[0].getFollowings({ start: 0, count: 2, sort: 'createdAt' }) - expect(body.total).to.equal(1) + await waitJobs(servers) - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(1) + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server2') + } - expect(follows[0].following.host).to.equal('localhost:' + servers[1].port) - }) + { + const { total, data } = await servers[1].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server2') + } - it('Should not have server 1 as follower on server 3 anymore', async function () { - const body = await followsCommands[2].getFollowers({ start: 0, count: 1, sort: 'createdAt' }) - expect(body.total).to.equal(0) + { + const { total, data } = await servers[2].videos.list() + expect(total).to.equal(1) + expect(data[0].name).to.equal('server3') + } + }) - const follows = body.data - expect(follows).to.be.an('array') - expect(follows.length).to.equal(0) - }) + it('Should remove account follow', async function () { + this.timeout(15000) - it('Should have the correct follows counts 2', async function () { - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) + await servers[0].follows.unfollow({ target: 'root@' + servers[1].host }) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) + await waitJobs(servers) + }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 0 }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 0, following: 0 }) - }) + it('Should have removed the account follow', async function () { + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) - it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () { - this.timeout(60000) + { + const { total, data } = await servers[0].follows.getFollowings() + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } - await servers[1].videos.upload({ attributes: { name: 'server2' } }) - await servers[2].videos.upload({ attributes: { name: 'server3' } }) + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) - await waitJobs(servers) + it('Should follow a channel', async function () { + this.timeout(15000) - { - const { total, data } = await servers[0].videos.list() - expect(total).to.equal(1) - expect(data[0].name).to.equal('server2') - } + await servers[0].follows.follow({ + handles: [ 'root_channel@' + servers[1].host ] + }) - { - const { total, data } = await servers[1].videos.list() - expect(total).to.equal(1) - expect(data[0].name).to.equal('server2') - } + await waitJobs(servers) - { - const { total, data } = await servers[2].videos.list() - expect(total).to.equal(1) - expect(data[0].name).to.equal('server3') - } + await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + + { + const { total, data } = await servers[0].follows.getFollowings() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + + { + const { total, data } = await servers[0].videos.list() + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + }) }) - describe('Should propagate data on a new following', function () { - let video4: Video + describe('Should propagate data on a new server follow', function () { + let video4: VideoCreateResult before(async function () { this.timeout(50000) @@ -324,83 +385,64 @@ describe('Test follows', function () { await servers[2].videos.upload({ attributes: { name: 'server3-2' } }) await servers[2].videos.upload({ attributes: { name: 'server3-3' } }) - await servers[2].videos.upload({ attributes: video4Attributes }) + video4 = await servers[2].videos.upload({ attributes: video4Attributes }) await servers[2].videos.upload({ attributes: { name: 'server3-5' } }) await servers[2].videos.upload({ attributes: { name: 'server3-6' } }) { const userAccessToken = await servers[2].users.generateUserAndToken('captain') - const { data } = await servers[2].videos.list() - video4 = data.find(v => v.name === 'server3-4') - - { - await servers[2].videos.rate({ id: video4.id, rating: 'like' }) - await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' }) - } - - { - { - const text = 'my super first comment' - const created = await servers[2].comments.createThread({ videoId: video4.id, text }) - const threadId = created.id - - const text1 = 'my super answer to thread 1' - const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 }) - - const text2 = 'my super answer to answer of thread 1' - await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text2 }) - - const text3 = 'my second answer to thread 1' - await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text3 }) - } + await servers[2].videos.rate({ id: video4.id, rating: 'like' }) + await servers[2].videos.rate({ token: userAccessToken, id: video4.id, rating: 'dislike' }) + } - { - const text = 'will be deleted' - const created = await servers[2].comments.createThread({ videoId: video4.id, text }) - const threadId = created.id + { + await servers[2].comments.createThread({ videoId: video4.id, text: 'my super first comment' }) - const text1 = 'answer to deleted' - await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text1 }) + await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' }) + await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' }) + await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' }) + } - const text2 = 'will also be deleted' - const childComment = await servers[2].comments.addReply({ videoId: video4.id, toCommentId: threadId, text: text2 }) + { + const { id: threadId } = await servers[2].comments.createThread({ videoId: video4.id, text: 'will be deleted' }) + await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' }) - const text3 = 'my second answer to deleted' - await servers[2].comments.addReply({ videoId: video4.id, toCommentId: childComment.id, text: text3 }) + const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' }) - await servers[2].comments.delete({ videoId: video4.id, commentId: threadId }) - await servers[2].comments.delete({ videoId: video4.id, commentId: childComment.id }) - } - } + await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' }) - { - await servers[2].captions.createVideoCaption({ - language: 'ar', - videoId: video4.id, - fixture: 'subtitle-good2.vtt' - }) - } + await servers[2].comments.delete({ videoId: video4.id, commentId: threadId }) + await servers[2].comments.delete({ videoId: video4.id, commentId: replyId }) } + await servers[2].captions.createVideoCaption({ + language: 'ar', + videoId: video4.id, + fixture: 'subtitle-good2.vtt' + }) + await waitJobs(servers) // Server 1 follows server 3 - await followsCommands[0].follow({ targets: [ servers[2].url ] }) + await servers[0].follows.follow({ hosts: [ servers[2].url ] }) await waitJobs(servers) }) - it('Should have the correct follows counts 3', async function () { - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 2 }) - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) - await expectAccountFollows({ server: servers[0], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) + it('Should have the correct follows counts', async function () { + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) + await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[1], handle: 'peertube@localhost:' + servers[1].port, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) + await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) + await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[0].port, followers: 0, following: 1 }) - await expectAccountFollows({ server: servers[2], handle: 'peertube@localhost:' + servers[2].port, followers: 1, following: 0 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) + await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) }) it('Should have propagated videos', async function () { @@ -426,7 +468,7 @@ describe('Test follows', function () { support: 'my super support text', account: { name: 'root', - host: 'localhost:' + servers[2].port + host: servers[2].host }, isLocal, commentsEnabled: true, @@ -467,7 +509,7 @@ describe('Test follows', function () { expect(comment.videoId).to.equal(video4.id) expect(comment.id).to.equal(comment.threadId) expect(comment.account.name).to.equal('root') - expect(comment.account.host).to.equal('localhost:' + servers[2].port) + expect(comment.account.host).to.equal(servers[2].host) expect(comment.totalReplies).to.equal(3) expect(dateIsValid(comment.createdAt as string)).to.be.true expect(dateIsValid(comment.updatedAt as string)).to.be.true @@ -541,14 +583,39 @@ describe('Test follows', function () { it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { this.timeout(5000) - await followsCommands[0].unfollow({ target: servers[2] }) + await servers[0].follows.unfollow({ target: servers[2] }) await waitJobs(servers) const { total } = await servers[0].videos.list() expect(total).to.equal(1) }) + }) + + describe('Should propagate data on a new channel follow', function () { + + before(async function () { + this.timeout(60000) + await servers[2].videos.upload({ attributes: { name: 'server3-7' } }) + + await waitJobs(servers) + + const video = await servers[0].videos.find({ name: 'server3-7' }) + expect(video).to.not.exist + }) + + it('Should have propagated channel video', async function () { + this.timeout(60000) + + await servers[0].follows.follow({ handles: [ 'root_channel@' + servers[2].host ] }) + + await waitJobs(servers) + + const video = await servers[0].videos.find({ name: 'server3-7' }) + + expect(video).to.exist + }) }) after(async function () { 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 () { this.timeout(240000) // Server 2 and 3 follow server 1 - await servers[1].follows.follow({ targets: [ servers[0].url ] }) - await servers[2].follows.follow({ targets: [ servers[0].url ] }) + await servers[1].follows.follow({ hosts: [ servers[0].url ] }) + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) await waitJobs(servers) @@ -180,7 +180,7 @@ describe('Test handle downs', function () { await servers[1].follows.unfollow({ target: servers[0] }) await waitJobs(servers) - await servers[1].follows.follow({ targets: [ servers[0].url ] }) + await servers[1].follows.follow({ hosts: [ servers[0].url ] }) await waitJobs(servers) 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 () { // Wait the video views repeatable job await wait(8000) - await servers[2].follows.follow({ targets: [ servers[0].url ] }) + await servers[2].follows.follow({ hosts: [ servers[0].url ] }) await waitJobs(servers) }) 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 () { it('Should have server 1 follow server 3 and display server 3 videos', async function () { this.timeout(60000) - await servers[0].follows.follow({ targets: [ servers[2].url ] }) + await servers[0].follows.follow({ hosts: [ servers[2].url ] }) await waitJobs(servers) 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 () { token = 'my_super_token' await server.follows.follow({ - targets: [ 'http://example.com' ], + hosts: [ 'http://example.com' ], token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 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 @@ import { pick } from 'lodash' -import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList } from '@shared/models' +import { ActivityPubActorType, ActorFollow, FollowState, HttpStatusCode, ResultList, ServerFollowCreate } from '@shared/models' import { AbstractCommand, OverrideCommandOptions } from '../shared' import { PeerTubeServer } from './server' @@ -29,13 +29,13 @@ export class FollowsCommand extends AbstractCommand { } getFollowings (options: OverrideCommandOptions & { - start: number - count: number - sort: string + start?: number + count?: number + sort?: string search?: string actorType?: ActivityPubActorType state?: FollowState - }) { + } = {}) { const path = '/api/v1/server/following' const toPick = [ 'start', 'count', 'sort', 'search', 'state', 'actorType' ] @@ -52,26 +52,41 @@ export class FollowsCommand extends AbstractCommand { } follow (options: OverrideCommandOptions & { - targets: string[] + hosts?: string[] + handles?: string[] }) { const path = '/api/v1/server/following' - const hosts = options.targets.map(f => f.replace(/^http:\/\//, '')) + const fields: ServerFollowCreate = {} + + if (options.hosts) { + fields.hosts = options.hosts.map(f => f.replace(/^http:\/\//, '')) + } + + if (options.handles) { + fields.handles = options.handles + } return this.postBodyRequest({ ...options, path, - fields: { hosts }, + fields, implicitToken: true, defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 }) } async unfollow (options: OverrideCommandOptions & { - target: PeerTubeServer + target: PeerTubeServer | string }) { - const path = '/api/v1/server/following/' + options.target.host + const { target } = options + + const handle = typeof target === 'string' + ? target + : target.host + + const path = '/api/v1/server/following/' + handle return this.deleteRequest({ ...options, 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' async function doubleFollow (server1: PeerTubeServer, server2: PeerTubeServer) { await Promise.all([ - server1.follows.follow({ targets: [ server2.url ] }), - server2.follows.follow({ targets: [ server1.url ] }) + server1.follows.follow({ hosts: [ server2.url ] }), + server2.follows.follow({ hosts: [ server1.url ] }) ]) // Wait request propagation diff --git a/shared/extra-utils/users/accounts.ts b/shared/extra-utils/users/actors.ts similarity index 50% rename from shared/extra-utils/users/accounts.ts rename to 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' import { pathExists, readdir } from 'fs-extra' import { join } from 'path' import { root } from '@server/helpers/core-utils' +import { Account, VideoChannel } from '@shared/models' import { PeerTubeServer } from '../server' -async function expectAccountFollows (options: { +async function expectChannelsFollows (options: { server: PeerTubeServer handle: string followers: number following: number }) { - const { server, handle, followers, following } = options + const { server } = options + const { data } = await server.channels.list() - const body = await server.accounts.list() - const account = body.data.find(a => a.name + '@' + a.host === handle) + return expectActorFollow({ ...options, data }) +} - const message = `${handle} on ${server.url}` - expect(account.followersCount).to.equal(followers, message) - expect(account.followingCount).to.equal(following, message) +async function expectAccountFollows (options: { + server: PeerTubeServer + handle: string + followers: number + following: number +}) { + const { server } = options + const { data } = await server.accounts.list() + + return expectActorFollow({ ...options, data }) } async function checkActorFilesWereRemoved (filename: string, serverNumber: number) { @@ -40,5 +49,25 @@ async function checkActorFilesWereRemoved (filename: string, serverNumber: numbe export { expectAccountFollows, + expectChannelsFollows, checkActorFilesWereRemoved } + +// --------------------------------------------------------------------------- + +function expectActorFollow (options: { + server: PeerTubeServer + data: (Account | VideoChannel)[] + handle: string + followers: number + following: number +}) { + const { server, data, handle, followers, following } = options + + const actor = data.find(a => a.name + '@' + a.host === handle) + const message = `${handle} on ${server.url}` + + expect(actor, message).to.exist + expect(actor.followersCount).to.equal(followers, message) + expect(actor.followingCount).to.equal(following, message) +} 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 @@ export * from './accounts-command' -export * from './accounts' +export * from './actors' export * from './blocklist-command' export * from './login' export * 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' export class CommentsCommand extends AbstractCommand { + private lastVideoId: number | string + private lastThreadId: number + private lastReplyId: number + listForAdmin (options: OverrideCommandOptions & { start?: number count?: number @@ -80,6 +84,9 @@ export class CommentsCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.OK_200 })) + this.lastThreadId = body.comment.id + this.lastVideoId = videoId + return body.comment } @@ -100,9 +107,23 @@ export class CommentsCommand extends AbstractCommand { defaultExpectedStatus: HttpStatusCode.OK_200 })) + this.lastReplyId = body.comment.id + return body.comment } + async addReplyToLastReply (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId }) + } + + async addReplyToLastThread (options: OverrideCommandOptions & { + text: string + }) { + return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId }) + } + async findCommentId (options: OverrideCommandOptions & { videoId: number | string text: string 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 { // --------------------------------------------------------------------------- + async find (options: OverrideCommandOptions & { + name: string + }) { + const { data } = await this.list(options) + + return data.find(v => v.name === options.name) + } + + // --------------------------------------------------------------------------- + update (options: OverrideCommandOptions & { id: number | string attributes?: VideoEdit 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' export * from './server-config.model' export * from './server-debug.model' export * from './server-error-code.enum' +export * from './server-follow-create.model' export * from './server-stats.model' diff --git a/shared/models/server/server-follow-create.model.ts b/shared/models/server/server-follow-create.model.ts new file mode 100644 index 000000000..3f90c7d6f --- /dev/null +++ b/shared/models/server/server-follow-create.model.ts @@ -0,0 +1,4 @@ +export interface ServerFollowCreate { + hosts?: string[] + handles?: string[] +} 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: - admin tags: - Instance Follows - summary: Follow a list of servers + summary: Follow a list of actors (PeerTube instance, channel or account) responses: '204': description: successful operation @@ -734,28 +734,32 @@ paths: type: string format: hostname uniqueItems: true + handles: + type: array + items: + type: string + uniqueItems: true - '/server/following/{host}': + '/server/following/{hostOrHandle}': delete: - summary: Unfollow a server + summary: Unfollow an actor (PeerTube instance, channel or account) security: - OAuth2: - admin tags: - Instance Follows parameters: - - name: host + - name: hostOrHandle in: path required: true - description: The host to unfollow + description: The hostOrHandle to unfollow schema: type: string - format: hostname responses: '204': description: successful operation '404': - description: host not found + description: host or handle not found /users: post: -- 2.41.0