Verified Commit 4d029ef8 authored by Chocobozzz's avatar Chocobozzz
Browse files

Add ability for instances to follow any actor

parent 7f28f2dd
......@@ -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'
},
......
......@@ -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,
......
<h1>
<my-global-icon iconName="follower" aria-hidden="true"></my-global-icon>
<ng-container i18n>Instances following you</ng-container>
<ng-container i18n>Followers of your instance</ng-container>
</h1>
<p-table
......@@ -21,7 +21,7 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 150px;" i18n>Actions</th>
<th i18n>Follower handle</th>
<th i18n>Follower</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
......
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">Follow</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
<div class="modal-body">
<form novalidate [formGroup]="form" (ngSubmit)="submit()">
<div class="form-group">
<label i18n for="hostsOrHandles">1 host (without "http://"), account handle or channel handle per line</label>
<textarea
[placeholder]="placeholder" formControlName="hostsOrHandles" type="text" id="hostsOrHandles" name="hostsOrHandles"
class="form-control" [ngClass]="{ 'input-error': formErrors['hostsOrHandles'] }" ngbAutofocus
></textarea>
<div *ngIf="formErrors.hostsOrHandles" class="form-error">
{{ formErrors.hostsOrHandles }}
<div *ngIf="form.controls['hostsOrHandles'].errors.validHostsOrHandles">
{{ form.controls['hostsOrHandles'].errors.validHostsOrHandles.value }}
</div>
</div>
</div>
<div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
</div>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Follow" class="peertube-button orange-button" [disabled]="!form.valid" />
</div>
</form>
</div>
</ng-template>
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@Component({
selector: 'my-follow-modal',
templateUrl: './follow-modal.component.html',
styleUrls: [ './follow-modal.component.scss' ]
})
export class FollowModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
@Output() newFollow = new EventEmitter<void>()
placeholder = 'example.com\nchocobozzz@example.com\nchocobozzz_channel@example.com'
private openedModal: NgbModalRef
constructor (
protected formValidatorService: FormValidatorService,
private modalService: NgbModal,
private followService: InstanceFollowService,
private notifier: Notifier
) {
super()
}
ngOnInit () {
this.buildForm({
hostsOrHandles: UNIQUE_HOSTS_OR_HANDLE_VALIDATOR
})
}
openModal () {
this.openedModal = this.modalService.open(this.modal, { centered: true })
}
hide () {
this.openedModal.close()
}
submit () {
this.addFollowing()
this.form.reset()
this.hide()
}
httpEnabled () {
return window.location.protocol === 'https:'
}
private async addFollowing () {
const hostsOrHandles = splitAndGetNotEmpty(this.form.value['hostsOrHandles'])
this.followService.follow(hostsOrHandles).subscribe(
() => {
this.notifier.success($localize`Follow request(s) sent!`)
this.newFollow.emit()
},
err => this.notifier.error(err.message)
)
}
}
<h1>
<my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<ng-container i18n>Instances you follow</ng-container>
<ng-container i18n>Your instance subscriptions</ng-container>
</h1>
<p-table
......@@ -13,9 +13,9 @@
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<a class="follow-button" (click)="addDomainsToFollow()" (key.enter)="addDomainsToFollow()">
<a class="follow-button" (click)="openFollowModal()" (key.enter)="openFollowModal()">
<my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<ng-container i18n>Follow instances</ng-container>
<ng-container i18n>Follow</ng-container>
</a>
</div>
......@@ -28,7 +28,7 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 150px;" i18n>Action</th>
<th i18n>Host</th>
<th i18n>Following</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
......@@ -41,8 +41,8 @@
<my-delete-button label="Unfollow" i18n-label (click)="removeFollowing(follow)"></my-delete-button>
</td>
<td>
<a [href]="'https://' + follow.following.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
{{ follow.following.host }}
<a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
{{ follow.following.name + '@' + follow.following.host }}
<span class="glyphicon glyphicon-new-window"></span>
</a>
</td>
......@@ -57,6 +57,7 @@
<td>{{ follow.createdAt | date: 'short' }}</td>
<td>
<my-redundancy-checkbox
*ngIf="isInstanceFollowing(follow)"
[host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
></my-redundancy-checkbox>
</td>
......@@ -75,10 +76,4 @@
</ng-template>
</p-table>
<my-batch-domains-modal #batchDomainsModal i18n-action action="Follow domains" (domains)="addFollowing($event)">
<ng-container ngProjectAs="warning">
<div i18n *ngIf="httpEnabled() === false" class="alert alert-warning">
It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers.
</div>
</ng-container>
</my-batch-domains-modal>
<my-follow-modal #followModal></my-follow-modal>
......@@ -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) {
......
export * from './follow-modal.component'
export * from './following-list.component'
......@@ -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`
}
}
},
......
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.`
}
}
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.`
}
}
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)
}
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'
......
......@@ -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))
......
<ng-template #modal>
<div class="modal-header">
<h4 i18n class="modal-title">{{ action }}</h4>
<h4 class="modal-title">{{ action }}</h4>
<my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
</div>
......@@ -11,15 +11,15 @@
<label i18n for="hosts">1 host (without "http://") per line</label>
<textarea
[placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
[placeholder]="placeholder" formControlName="hosts" type="text" id="hosts" name="hosts"
class="form-control" [ngClass]="{ 'input-error': formErrors['hosts'] }" ngbAutofocus
></textarea>
<div *ngIf="formErrors.domains" class="form-error">
{{ formErrors.domains }}
<div *ngIf="formErrors.hosts" class="form-error">
{{ formErrors.hosts }}
<div *ngIf="form.controls['domains'].errors.validDomains">
{{ form.controls['domains'].errors.validDomains.value }}
<div *ngIf="form.controls['hosts'].errors.validHosts">
{{ form.controls['hosts'].errors.validHosts.value }}
</div>
</div>
</div>
......
......@@ -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()
}
......
......@@ -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',
...