aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-moderation
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/shared-moderation')
-rw-r--r--client/src/app/shared/shared-moderation/account-block.model.ts14
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.html64
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.scss16
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.ts78
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.html43
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.scss3
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.ts52
-rw-r--r--client/src/app/shared/shared-moderation/blocklist.service.ts153
-rw-r--r--client/src/app/shared/shared-moderation/bulk.service.ts23
-rw-r--r--client/src/app/shared/shared-moderation/index.ts13
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.html59
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.scss34
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.ts100
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts46
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.html38
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.scss6
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts68
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html9
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts379
-rw-r--r--client/src/app/shared/shared-moderation/video-abuse.service.ts98
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.html45
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.scss6
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.ts74
-rw-r--r--client/src/app/shared/shared-moderation/video-block.service.ts78
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.html97
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.scss27
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.ts161
27 files changed, 1784 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-moderation/account-block.model.ts b/client/src/app/shared/shared-moderation/account-block.model.ts
new file mode 100644
index 000000000..8f76c69dc
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/account-block.model.ts
@@ -0,0 +1,14 @@
1import { AccountBlock as AccountBlockServer } from '@shared/models'
2import { Account } from '@app/shared/shared-main'
3
4export class AccountBlock implements AccountBlockServer {
5 byAccount: Account
6 blockedAccount: Account
7 createdAt: Date | string
8
9 constructor (block: AccountBlockServer) {
10 this.byAccount = new Account(block.byAccount)
11 this.blockedAccount = new Account(block.blockedAccount)
12 this.createdAt = block.createdAt
13 }
14}
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.html b/client/src/app/shared/shared-moderation/account-blocklist.component.html
new file mode 100644
index 000000000..486785f35
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.html
@@ -0,0 +1,64 @@
1<p-table
2 [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
6>
7 <ng-template pTemplate="caption">
8 <div class="caption">
9 <div class="ml-auto has-feedback has-clear">
10 <input
11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
12 (keyup)="onSearch($event)"
13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
16 </div>
17 </div>
18 </ng-template>
19
20 <ng-template pTemplate="header">
21 <tr>
22 <th style="width: 100%;" i18n>Account</th>
23 <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
24 <th style="width: 150px;"></th> <!-- column for action buttons -->
25 </tr>
26 </ng-template>
27
28 <ng-template pTemplate="body" let-accountBlock>
29 <tr>
30 <td>
31 <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
32 <div class="chip two-lines">
33 <img
34 class="avatar"
35 [src]="accountBlock.blockedAccount.avatar?.path"
36 (error)="switchToDefaultAvatar($event)"
37 alt="Avatar"
38 >
39 <div>
40 {{ accountBlock.blockedAccount.displayName }}
41 <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
42 </div>
43 </div>
44 </a>
45 </td>
46
47 <td>{{ accountBlock.createdAt | date: 'short' }}</td>
48 <td class="action-cell">
49 <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
50 </td>
51 </tr>
52 </ng-template>
53
54 <ng-template pTemplate="emptymessage">
55 <tr>
56 <td colspan="6">
57 <div class="no-results">
58 <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
59 <ng-container *ngIf="!search" i18n>No account found.</ng-container>
60 </div>
61 </td>
62 </tr>
63 </ng-template>
64</p-table>
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.scss b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
new file mode 100644
index 000000000..aa8363ff4
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
@@ -0,0 +1,16 @@
1@import '_variables';
2@import '_mixins';
3
4.caption {
5 justify-content: flex-end;
6
7 input {
8 @include peertube-input-text(250px);
9 flex-grow: 1;
10 }
11}
12
13.unblock-button {
14 @include peertube-button;
15 @include grey-button;
16} \ No newline at end of file
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.ts b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
new file mode 100644
index 000000000..38e0d0424
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
@@ -0,0 +1,78 @@
1import { SortMeta } from 'primeng/api'
2import { OnInit } from '@angular/core'
3import { Notifier, RestPagination, RestTable } from '@app/core'
4import { Actor } from '@app/shared/shared-main'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { AccountBlock } from './account-block.model'
7import { BlocklistComponentType, BlocklistService } from './blocklist.service'
8
9export class GenericAccountBlocklistComponent extends RestTable implements OnInit {
10 // @ts-ignore: "Abstract methods can only appear within an abstract class"
11 abstract mode: BlocklistComponentType
12
13 blockedAccounts: AccountBlock[] = []
14 totalRecords = 0
15 sort: SortMeta = { field: 'createdAt', order: -1 }
16 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
17
18 constructor (
19 private notifier: Notifier,
20 private blocklistService: BlocklistService,
21 private i18n: I18n
22 ) {
23 super()
24 }
25
26 // @ts-ignore: "Abstract methods can only appear within an abstract class"
27 abstract getIdentifier (): string
28
29 ngOnInit () {
30 this.initialize()
31 }
32
33 switchToDefaultAvatar ($event: Event) {
34 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
35 }
36
37 unblockAccount (accountBlock: AccountBlock) {
38 const blockedAccount = accountBlock.blockedAccount
39 const operation = this.mode === BlocklistComponentType.Account
40 ? this.blocklistService.unblockAccountByUser(blockedAccount)
41 : this.blocklistService.unblockAccountByInstance(blockedAccount)
42
43 operation.subscribe(
44 () => {
45 this.notifier.success(
46 this.mode === BlocklistComponentType.Account
47 ? this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: blockedAccount.nameWithHost })
48 : this.i18n('Account {{nameWithHost}} unmuted by your instance.', { nameWithHost: blockedAccount.nameWithHost })
49 )
50
51 this.loadData()
52 }
53 )
54 }
55
56 protected loadData () {
57 const operation = this.mode === BlocklistComponentType.Account
58 ? this.blocklistService.getUserAccountBlocklist({
59 pagination: this.pagination,
60 sort: this.sort,
61 search: this.search
62 })
63 : this.blocklistService.getInstanceAccountBlocklist({
64 pagination: this.pagination,
65 sort: this.sort,
66 search: this.search
67 })
68
69 return operation.subscribe(
70 resultList => {
71 this.blockedAccounts = resultList.data
72 this.totalRecords = resultList.total
73 },
74
75 err => this.notifier.error(err.message)
76 )
77 }
78}
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
new file mode 100644
index 000000000..1b85c8f48
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.html
@@ -0,0 +1,43 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">{{ action }}</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="hosts">1 host (without "http://") per line</label>
12
13 <textarea
14 [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
15 class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
16 ></textarea>
17
18 <div *ngIf="formErrors.domains" class="form-error">
19 {{ formErrors.domains }}
20
21 <div *ngIf="form.controls['domains'].errors.validDomains">
22 {{ form.controls['domains'].errors.validDomains.value }}
23 </div>
24 </div>
25 </div>
26
27 <ng-content select="warning"></ng-content>
28
29 <div class="form-group inputs">
30 <input
31 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
32 (click)="hide()" (key.enter)="hide()"
33 >
34
35 <input
36 type="submit" [value]="action" class="action-button-submit"
37 [disabled]="!form.valid"
38 >
39 </div>
40 </form>
41 </div>
42
43</ng-template>
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss b/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss
new file mode 100644
index 000000000..9621a566f
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.scss
@@ -0,0 +1,3 @@
1textarea {
2 height: 200px;
3}
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
new file mode 100644
index 000000000..fdd4a79a9
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
@@ -0,0 +1,52 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { BatchDomainsValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6
7@Component({
8 selector: 'my-batch-domains-modal',
9 templateUrl: './batch-domains-modal.component.html',
10 styleUrls: [ './batch-domains-modal.component.scss' ]
11})
12export class BatchDomainsModalComponent extends FormReactive implements OnInit {
13 @ViewChild('modal', { static: true }) modal: NgbModal
14 @Input() placeholder = 'example.com'
15 @Input() action: string
16 @Output() domains = new EventEmitter<string[]>()
17
18 private openedModal: NgbModalRef
19
20 constructor (
21 protected formValidatorService: FormValidatorService,
22 private modalService: NgbModal,
23 private batchDomainsValidatorsService: BatchDomainsValidatorsService,
24 private i18n: I18n
25 ) {
26 super()
27 }
28
29 ngOnInit () {
30 if (!this.action) this.action = this.i18n('Process domains')
31
32 this.buildForm({
33 domains: this.batchDomainsValidatorsService.DOMAINS
34 })
35 }
36
37 openModal () {
38 this.openedModal = this.modalService.open(this.modal, { centered: true })
39 }
40
41 hide () {
42 this.openedModal.close()
43 }
44
45 submit () {
46 this.domains.emit(
47 this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value)
48 )
49 this.form.reset()
50 this.hide()
51 }
52}
diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts
new file mode 100644
index 000000000..0caa92782
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/blocklist.service.ts
@@ -0,0 +1,153 @@
1import { SortMeta } from 'primeng/api'
2import { catchError, map } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor, RestPagination, RestService } from '@app/core'
6import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '@shared/models'
7import { environment } from '../../../environments/environment'
8import { Account } from '../shared-main'
9import { AccountBlock } from './account-block.model'
10
11export enum BlocklistComponentType { Account, Instance }
12
13@Injectable()
14export class BlocklistService {
15 static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist'
16 static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist'
17
18 constructor (
19 private authHttp: HttpClient,
20 private restExtractor: RestExtractor,
21 private restService: RestService
22 ) { }
23
24 /*********************** User -> Account blocklist ***********************/
25
26 getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
27 const { pagination, sort, search } = options
28
29 let params = new HttpParams()
30 params = this.restService.addRestGetParams(params, pagination, sort)
31
32 if (search) params = params.append('search', search)
33
34 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params })
35 .pipe(
36 map(res => this.restExtractor.convertResultListDateToHuman(res)),
37 map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
38 catchError(err => this.restExtractor.handleError(err))
39 )
40 }
41
42 blockAccountByUser (account: Account) {
43 const body = { accountName: account.nameWithHost }
44
45 return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body)
46 .pipe(catchError(err => this.restExtractor.handleError(err)))
47 }
48
49 unblockAccountByUser (account: Account) {
50 const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
51
52 return this.authHttp.delete(path)
53 .pipe(catchError(err => this.restExtractor.handleError(err)))
54 }
55
56 /*********************** User -> Server blocklist ***********************/
57
58 getUserServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
59 const { pagination, sort, search } = options
60
61 let params = new HttpParams()
62 params = this.restService.addRestGetParams(params, pagination, sort)
63
64 if (search) params = params.append('search', search)
65
66 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params })
67 .pipe(
68 map(res => this.restExtractor.convertResultListDateToHuman(res)),
69 catchError(err => this.restExtractor.handleError(err))
70 )
71 }
72
73 blockServerByUser (host: string) {
74 const body = { host }
75
76 return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body)
77 .pipe(catchError(err => this.restExtractor.handleError(err)))
78 }
79
80 unblockServerByUser (host: string) {
81 const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host
82
83 return this.authHttp.delete(path)
84 .pipe(catchError(err => this.restExtractor.handleError(err)))
85 }
86
87 /*********************** Instance -> Account blocklist ***********************/
88
89 getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
90 const { pagination, sort, search } = options
91
92 let params = new HttpParams()
93 params = this.restService.addRestGetParams(params, pagination, sort)
94
95 if (search) params = params.append('search', search)
96
97 return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
98 .pipe(
99 map(res => this.restExtractor.convertResultListDateToHuman(res)),
100 map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
101 catchError(err => this.restExtractor.handleError(err))
102 )
103 }
104
105 blockAccountByInstance (account: Account) {
106 const body = { accountName: account.nameWithHost }
107
108 return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body)
109 .pipe(catchError(err => this.restExtractor.handleError(err)))
110 }
111
112 unblockAccountByInstance (account: Account) {
113 const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
114
115 return this.authHttp.delete(path)
116 .pipe(catchError(err => this.restExtractor.handleError(err)))
117 }
118
119 /*********************** Instance -> Server blocklist ***********************/
120
121 getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
122 const { pagination, sort, search } = options
123
124 let params = new HttpParams()
125 params = this.restService.addRestGetParams(params, pagination, sort)
126
127 if (search) params = params.append('search', search)
128
129 return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
130 .pipe(
131 map(res => this.restExtractor.convertResultListDateToHuman(res)),
132 catchError(err => this.restExtractor.handleError(err))
133 )
134 }
135
136 blockServerByInstance (host: string) {
137 const body = { host }
138
139 return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body)
140 .pipe(catchError(err => this.restExtractor.handleError(err)))
141 }
142
143 unblockServerByInstance (host: string) {
144 const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host
145
146 return this.authHttp.delete(path)
147 .pipe(catchError(err => this.restExtractor.handleError(err)))
148 }
149
150 private formatAccountBlock (accountBlock: AccountBlockServer) {
151 return new AccountBlock(accountBlock)
152 }
153}
diff --git a/client/src/app/shared/shared-moderation/bulk.service.ts b/client/src/app/shared/shared-moderation/bulk.service.ts
new file mode 100644
index 000000000..f0b869421
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/bulk.service.ts
@@ -0,0 +1,23 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { BulkRemoveCommentsOfBody } from '@shared/models'
6import { environment } from '../../../environments/environment'
7
8@Injectable()
9export class BulkService {
10 static BASE_BULK_URL = environment.apiUrl + '/api/v1/bulk'
11
12 constructor (
13 private authHttp: HttpClient,
14 private restExtractor: RestExtractor
15 ) { }
16
17 removeCommentsOf (body: BulkRemoveCommentsOfBody) {
18 const url = BulkService.BASE_BULK_URL + '/remove-comments-of'
19
20 return this.authHttp.post(url, body)
21 .pipe(catchError(err => this.restExtractor.handleError(err)))
22 }
23}
diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts
new file mode 100644
index 000000000..8e74254f6
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/index.ts
@@ -0,0 +1,13 @@
1export * from './account-block.model'
2export * from './account-blocklist.component'
3export * from './batch-domains-modal.component'
4export * from './blocklist.service'
5export * from './bulk.service'
6export * from './server-blocklist.component'
7export * from './user-ban-modal.component'
8export * from './user-moderation-dropdown.component'
9export * from './video-abuse.service'
10export * from './video-block.component'
11export * from './video-block.service'
12export * from './video-report.component'
13export * from './shared-moderation.module'
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.html b/client/src/app/shared/shared-moderation/server-blocklist.component.html
new file mode 100644
index 000000000..977e0e141
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.html
@@ -0,0 +1,59 @@
1<p-table
2 [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
6>
7 <ng-template pTemplate="caption">
8 <div class="caption">
9 <div class="ml-auto has-feedback has-clear">
10 <input
11 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
12 (keyup)="onSearch($event)"
13 >
14 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
15 <span class="sr-only" i18n>Clear filters</span>
16 </div>
17 <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
18 <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
19 <ng-container i18n>Mute domain</ng-container>
20 </a>
21 </div>
22 </ng-template>
23
24 <ng-template pTemplate="header">
25 <tr>
26 <th style="width: 100%;" i18n>Instance</th>
27 <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
28 <th style="width: 150px;"></th> <!-- column for action buttons -->
29 </tr>
30 </ng-template>
31
32 <ng-template pTemplate="body" let-serverBlock>
33 <tr>
34 <td>
35 <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
36 {{ serverBlock.blockedServer.host }}
37 <span class="glyphicon glyphicon-new-window"></span>
38 </a>
39 </td>
40 <td>{{ serverBlock.createdAt | date: 'short' }}</td>
41 <td class="action-cell">
42 <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
43 </td>
44 </tr>
45 </ng-template>
46
47 <ng-template pTemplate="emptymessage">
48 <tr>
49 <td colspan="6">
50 <div class="no-results">
51 <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
52 <ng-container *ngIf="!search" i18n>No server found.</ng-container>
53 </div>
54 </td>
55 </tr>
56 </ng-template>
57</p-table>
58
59<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
new file mode 100644
index 000000000..9ddb76850
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
@@ -0,0 +1,34 @@
1@import '_variables';
2@import '_mixins';
3
4a {
5 @include disable-default-a-behaviour;
6 display: inline-block;
7
8 &, &:hover {
9 color: pvar(--mainForegroundColor);
10 }
11
12 span {
13 font-size: 80%;
14 color: pvar(--inputPlaceholderColor);
15 }
16}
17
18.caption {
19 justify-content: flex-end;
20
21 input {
22 @include peertube-input-text(250px);
23 flex-grow: 1;
24 }
25}
26
27.unblock-button {
28 @include peertube-button;
29 @include grey-button;
30}
31
32.block-button {
33 @include create-button;
34}
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.ts b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
new file mode 100644
index 000000000..d904d0605
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
@@ -0,0 +1,100 @@
1import { SortMeta } from 'primeng/api'
2import { OnInit, ViewChild } from '@angular/core'
3import { BatchDomainsModalComponent } from '@app/shared/shared-moderation/batch-domains-modal.component'
4import { Notifier, RestPagination, RestTable } from '@app/core'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { ServerBlock } from '@shared/models'
7import { BlocklistComponentType, BlocklistService } from './blocklist.service'
8
9export class GenericServerBlocklistComponent extends RestTable implements OnInit {
10 @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
11
12 // @ts-ignore: "Abstract methods can only appear within an abstract class"
13 public abstract mode: BlocklistComponentType
14
15 blockedServers: ServerBlock[] = []
16 totalRecords = 0
17 sort: SortMeta = { field: 'createdAt', order: -1 }
18 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
19
20 constructor (
21 protected notifier: Notifier,
22 protected blocklistService: BlocklistService,
23 protected i18n: I18n
24 ) {
25 super()
26 }
27
28 // @ts-ignore: "Abstract methods can only appear within an abstract class"
29 public abstract getIdentifier (): string
30
31 ngOnInit () {
32 this.initialize()
33 }
34
35 unblockServer (serverBlock: ServerBlock) {
36 const operation = (host: string) => this.mode === BlocklistComponentType.Account
37 ? this.blocklistService.unblockServerByUser(host)
38 : this.blocklistService.unblockServerByInstance(host)
39 const host = serverBlock.blockedServer.host
40
41 operation(host).subscribe(
42 () => {
43 this.notifier.success(
44 this.mode === BlocklistComponentType.Account
45 ? this.i18n('Instance {{host}} unmuted.', { host })
46 : this.i18n('Instance {{host}} unmuted by your instance.', { host })
47 )
48
49 this.loadData()
50 }
51 )
52 }
53
54 addServersToBlock () {
55 this.batchDomainsModal.openModal()
56 }
57
58 onDomainsToBlock (domains: string[]) {
59 const operation = (domain: string) => this.mode === BlocklistComponentType.Account
60 ? this.blocklistService.blockServerByUser(domain)
61 : this.blocklistService.blockServerByInstance(domain)
62
63 domains.forEach(domain => {
64 operation(domain).subscribe(
65 () => {
66 this.notifier.success(
67 this.mode === BlocklistComponentType.Account
68 ? this.i18n('Instance {{domain}} muted.', { domain })
69 : this.i18n('Instance {{domain}} muted by your instance.', { domain })
70 )
71
72 this.loadData()
73 }
74 )
75 })
76 }
77
78 protected loadData () {
79 const operation = this.mode === BlocklistComponentType.Account
80 ? this.blocklistService.getUserServerBlocklist({
81 pagination: this.pagination,
82 sort: this.sort,
83 search: this.search
84 })
85 : this.blocklistService.getInstanceServerBlocklist({
86 pagination: this.pagination,
87 sort: this.sort,
88 search: this.search
89 })
90
91 return operation.subscribe(
92 resultList => {
93 this.blockedServers = resultList.data
94 this.totalRecords = resultList.total
95 },
96
97 err => this.notifier.error(err.message)
98 )
99 }
100}
diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
new file mode 100644
index 000000000..f7e64dfa3
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
@@ -0,0 +1,46 @@
1
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '../shared-forms/shared-form.module'
4import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module'
6import { BatchDomainsModalComponent } from './batch-domains-modal.component'
7import { BlocklistService } from './blocklist.service'
8import { BulkService } from './bulk.service'
9import { UserBanModalComponent } from './user-ban-modal.component'
10import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
11import { VideoAbuseService } from './video-abuse.service'
12import { VideoBlockComponent } from './video-block.component'
13import { VideoBlockService } from './video-block.service'
14import { VideoReportComponent } from './video-report.component'
15
16@NgModule({
17 imports: [
18 SharedMainModule,
19 SharedFormModule,
20 SharedGlobalIconModule
21 ],
22
23 declarations: [
24 UserBanModalComponent,
25 UserModerationDropdownComponent,
26 VideoBlockComponent,
27 VideoReportComponent,
28 BatchDomainsModalComponent
29 ],
30
31 exports: [
32 UserBanModalComponent,
33 UserModerationDropdownComponent,
34 VideoBlockComponent,
35 VideoReportComponent,
36 BatchDomainsModalComponent
37 ],
38
39 providers: [
40 BlocklistService,
41 BulkService,
42 VideoAbuseService,
43 VideoBlockService
44 ]
45})
46export class SharedModerationModule { }
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.html b/client/src/app/shared/shared-moderation/user-ban-modal.component.html
new file mode 100644
index 000000000..365eb1938
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.html
@@ -0,0 +1,38 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Ban</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)="banUser()">
10 <div class="form-group">
11 <textarea
12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
14 ></textarea>
15 <div *ngIf="formErrors.reason" class="form-error">
16 {{ formErrors.reason }}
17 </div>
18 </div>
19
20 <div i18n>
21 A banned user will no longer be able to login.
22 </div>
23
24 <div class="form-group inputs">
25 <input
26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
27 (click)="hide()" (key.enter)="hide()"
28 >
29
30 <input
31 type="submit" i18n-value value="Ban this user" class="action-button-submit"
32 [disabled]="!form.valid"
33 >
34 </div>
35 </form>
36 </div>
37
38</ng-template>
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.scss b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss
new file mode 100644
index 000000000..84562f15c
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.scss
@@ -0,0 +1,6 @@
1@import 'variables';
2@import 'mixins';
3
4textarea {
5 @include peertube-textarea(100%, 60px);
6}
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
new file mode 100644
index 000000000..124e58669
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -0,0 +1,68 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, UserService } from '@app/core'
3import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { User } from '@shared/models'
8
9@Component({
10 selector: 'my-user-ban-modal',
11 templateUrl: './user-ban-modal.component.html',
12 styleUrls: [ './user-ban-modal.component.scss' ]
13})
14export class UserBanModalComponent extends FormReactive implements OnInit {
15 @ViewChild('modal', { static: true }) modal: NgbModal
16 @Output() userBanned = new EventEmitter<User | User[]>()
17
18 private usersToBan: User | User[]
19 private openedModal: NgbModalRef
20
21 constructor (
22 protected formValidatorService: FormValidatorService,
23 private modalService: NgbModal,
24 private notifier: Notifier,
25 private userService: UserService,
26 private userValidatorsService: UserValidatorsService,
27 private i18n: I18n
28 ) {
29 super()
30 }
31
32 ngOnInit () {
33 this.buildForm({
34 reason: this.userValidatorsService.USER_BAN_REASON
35 })
36 }
37
38 openModal (user: User | User[]) {
39 this.usersToBan = user
40 this.openedModal = this.modalService.open(this.modal, { centered: true })
41 }
42
43 hide () {
44 this.usersToBan = undefined
45 this.openedModal.close()
46 }
47
48 async banUser () {
49 const reason = this.form.value['reason'] || undefined
50
51 this.userService.banUsers(this.usersToBan, reason)
52 .subscribe(
53 () => {
54 const message = Array.isArray(this.usersToBan)
55 ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
56 : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
57
58 this.notifier.success(message)
59
60 this.userBanned.emit(this.usersToBan)
61 this.hide()
62 },
63
64 err => this.notifier.error(err.message)
65 )
66 }
67
68}
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html
new file mode 100644
index 000000000..4d562387a
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.html
@@ -0,0 +1,9 @@
1<ng-container *ngIf="userActions.length !== 0">
2 <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
3
4 <my-action-dropdown
5 [actions]="userActions" [entry]="{ user: user, account: account }"
6 [buttonSize]="buttonSize" [placement]="placement" [label]="label"
7 [container]="container"
8 ></my-action-dropdown>
9</ng-container>
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
new file mode 100644
index 000000000..d3c37f082
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -0,0 +1,379 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
2import { AuthService, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
3import { Account, DropdownAction } from '@app/shared/shared-main'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { BulkRemoveCommentsOfBody, ServerConfig, User, UserRight } from '@shared/models'
6import { BlocklistService } from './blocklist.service'
7import { BulkService } from './bulk.service'
8import { UserBanModalComponent } from './user-ban-modal.component'
9
10@Component({
11 selector: 'my-user-moderation-dropdown',
12 templateUrl: './user-moderation-dropdown.component.html'
13})
14export class UserModerationDropdownComponent implements OnInit, OnChanges {
15 @ViewChild('userBanModal') userBanModal: UserBanModalComponent
16
17 @Input() user: User
18 @Input() account: Account
19
20 @Input() buttonSize: 'normal' | 'small' = 'normal'
21 @Input() placement = 'left-top left-bottom auto'
22 @Input() label: string
23 @Input() container: 'body' | undefined = undefined
24
25 @Output() userChanged = new EventEmitter()
26 @Output() userDeleted = new EventEmitter()
27
28 userActions: DropdownAction<{ user: User, account: Account }>[][] = []
29
30 private serverConfig: ServerConfig
31
32 constructor (
33 private authService: AuthService,
34 private notifier: Notifier,
35 private confirmService: ConfirmService,
36 private serverService: ServerService,
37 private userService: UserService,
38 private blocklistService: BlocklistService,
39 private bulkService: BulkService,
40 private i18n: I18n
41 ) { }
42
43 get requiresEmailVerification () {
44 return this.serverConfig.signup.requiresEmailVerification
45 }
46
47 ngOnInit (): void {
48 this.serverConfig = this.serverService.getTmpConfig()
49 this.serverService.getConfig()
50 .subscribe(config => this.serverConfig = config)
51 }
52
53 ngOnChanges () {
54 this.buildActions()
55 }
56
57 openBanUserModal (user: User) {
58 if (user.username === 'root') {
59 this.notifier.error(this.i18n('You cannot ban root.'))
60 return
61 }
62
63 this.userBanModal.openModal(user)
64 }
65
66 onUserBanned () {
67 this.userChanged.emit()
68 }
69
70 async unbanUser (user: User) {
71 const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
72 const res = await this.confirmService.confirm(message, this.i18n('Unban'))
73 if (res === false) return
74
75 this.userService.unbanUsers(user)
76 .subscribe(
77 () => {
78 this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username }))
79
80 this.userChanged.emit()
81 },
82
83 err => this.notifier.error(err.message)
84 )
85 }
86
87 async removeUser (user: User) {
88 if (user.username === 'root') {
89 this.notifier.error(this.i18n('You cannot delete root.'))
90 return
91 }
92
93 const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
94 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
95 if (res === false) return
96
97 this.userService.removeUser(user).subscribe(
98 () => {
99 this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username }))
100 this.userDeleted.emit()
101 },
102
103 err => this.notifier.error(err.message)
104 )
105 }
106
107 setEmailAsVerified (user: User) {
108 this.userService.updateUser(user.id, { emailVerified: true }).subscribe(
109 () => {
110 this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username }))
111
112 this.userChanged.emit()
113 },
114
115 err => this.notifier.error(err.message)
116 )
117 }
118
119 blockAccountByUser (account: Account) {
120 this.blocklistService.blockAccountByUser(account)
121 .subscribe(
122 () => {
123 this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost }))
124
125 this.account.mutedByUser = true
126 this.userChanged.emit()
127 },
128
129 err => this.notifier.error(err.message)
130 )
131 }
132
133 unblockAccountByUser (account: Account) {
134 this.blocklistService.unblockAccountByUser(account)
135 .subscribe(
136 () => {
137 this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost }))
138
139 this.account.mutedByUser = false
140 this.userChanged.emit()
141 },
142
143 err => this.notifier.error(err.message)
144 )
145 }
146
147 blockServerByUser (host: string) {
148 this.blocklistService.blockServerByUser(host)
149 .subscribe(
150 () => {
151 this.notifier.success(this.i18n('Instance {{host}} muted.', { host }))
152
153 this.account.mutedServerByUser = true
154 this.userChanged.emit()
155 },
156
157 err => this.notifier.error(err.message)
158 )
159 }
160
161 unblockServerByUser (host: string) {
162 this.blocklistService.unblockServerByUser(host)
163 .subscribe(
164 () => {
165 this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host }))
166
167 this.account.mutedServerByUser = false
168 this.userChanged.emit()
169 },
170
171 err => this.notifier.error(err.message)
172 )
173 }
174
175 blockAccountByInstance (account: Account) {
176 this.blocklistService.blockAccountByInstance(account)
177 .subscribe(
178 () => {
179 this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost }))
180
181 this.account.mutedByInstance = true
182 this.userChanged.emit()
183 },
184
185 err => this.notifier.error(err.message)
186 )
187 }
188
189 unblockAccountByInstance (account: Account) {
190 this.blocklistService.unblockAccountByInstance(account)
191 .subscribe(
192 () => {
193 this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost }))
194
195 this.account.mutedByInstance = false
196 this.userChanged.emit()
197 },
198
199 err => this.notifier.error(err.message)
200 )
201 }
202
203 blockServerByInstance (host: string) {
204 this.blocklistService.blockServerByInstance(host)
205 .subscribe(
206 () => {
207 this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host }))
208
209 this.account.mutedServerByInstance = true
210 this.userChanged.emit()
211 },
212
213 err => this.notifier.error(err.message)
214 )
215 }
216
217 unblockServerByInstance (host: string) {
218 this.blocklistService.unblockServerByInstance(host)
219 .subscribe(
220 () => {
221 this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host }))
222
223 this.account.mutedServerByInstance = false
224 this.userChanged.emit()
225 },
226
227 err => this.notifier.error(err.message)
228 )
229 }
230
231 async bulkRemoveCommentsOf (body: BulkRemoveCommentsOfBody) {
232 const message = this.i18n('Are you sure you want to remove all the comments of this account?')
233 const res = await this.confirmService.confirm(message, this.i18n('Delete account comments'))
234 if (res === false) return
235
236 this.bulkService.removeCommentsOf(body)
237 .subscribe(
238 () => {
239 this.notifier.success(this.i18n('Will remove comments of this account (may take several minutes).'))
240 },
241
242 err => this.notifier.error(err.message)
243 )
244 }
245
246 getRouterUserEditLink (user: User) {
247 return [ '/admin', 'users', 'update', user.id ]
248 }
249
250 private buildActions () {
251 this.userActions = []
252
253 if (this.authService.isLoggedIn()) {
254 const authUser = this.authService.getUser()
255
256 if (this.user && authUser.id === this.user.id) return
257
258 if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) {
259 this.userActions.push([
260 {
261 label: this.i18n('Edit user'),
262 description: this.i18n('Change quota, role, and more.'),
263 linkBuilder: ({ user }) => this.getRouterUserEditLink(user)
264 },
265 {
266 label: this.i18n('Delete user'),
267 description: this.i18n('Videos will be deleted, comments will be tombstoned.'),
268 handler: ({ user }) => this.removeUser(user)
269 },
270 {
271 label: this.i18n('Ban'),
272 description: this.i18n('User won\'t be able to login anymore, but videos and comments will be kept as is.'),
273 handler: ({ user }) => this.openBanUserModal(user),
274 isDisplayed: ({ user }) => !user.blocked
275 },
276 {
277 label: this.i18n('Unban user'),
278 description: this.i18n('Allow the user to login and create videos/comments again'),
279 handler: ({ user }) => this.unbanUser(user),
280 isDisplayed: ({ user }) => user.blocked
281 },
282 {
283 label: this.i18n('Set Email as Verified'),
284 handler: ({ user }) => this.setEmailAsVerified(user),
285 isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false
286 }
287 ])
288 }
289
290 // Actions on accounts/servers
291 if (this.account) {
292 // User actions
293 this.userActions.push([
294 {
295 label: this.i18n('Mute this account'),
296 description: this.i18n('Hide any content from that user for you.'),
297 isDisplayed: ({ account }) => account.mutedByUser === false,
298 handler: ({ account }) => this.blockAccountByUser(account)
299 },
300 {
301 label: this.i18n('Unmute this account'),
302 description: this.i18n('Show back content from that user for you.'),
303 isDisplayed: ({ account }) => account.mutedByUser === true,
304 handler: ({ account }) => this.unblockAccountByUser(account)
305 },
306 {
307 label: this.i18n('Mute the instance'),
308 description: this.i18n('Hide any content from that instance for you.'),
309 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
310 handler: ({ account }) => this.blockServerByUser(account.host)
311 },
312 {
313 label: this.i18n('Unmute the instance'),
314 description: this.i18n('Show back content from that instance for you.'),
315 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
316 handler: ({ account }) => this.unblockServerByUser(account.host)
317 },
318 {
319 label: this.i18n('Remove comments from your videos'),
320 description: this.i18n('Remove comments of this account from your videos.'),
321 handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'my-videos' })
322 }
323 ])
324
325 let instanceActions: DropdownAction<{ user: User, account: Account }>[] = []
326
327 // Instance actions on account blocklists
328 if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
329 instanceActions = instanceActions.concat([
330 {
331 label: this.i18n('Mute this account by your instance'),
332 description: this.i18n('Hide any content from that user for you, your instance and its users.'),
333 isDisplayed: ({ account }) => account.mutedByInstance === false,
334 handler: ({ account }) => this.blockAccountByInstance(account)
335 },
336 {
337 label: this.i18n('Unmute this account by your instance'),
338 description: this.i18n('Show back content from that user for you, your instance and its users.'),
339 isDisplayed: ({ account }) => account.mutedByInstance === true,
340 handler: ({ account }) => this.unblockAccountByInstance(account)
341 }
342 ])
343 }
344
345 // Instance actions on server blocklists
346 if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
347 instanceActions = instanceActions.concat([
348 {
349 label: this.i18n('Mute the instance by your instance'),
350 description: this.i18n('Hide any content from that instance for you, your instance and its users.'),
351 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
352 handler: ({ account }) => this.blockServerByInstance(account.host)
353 },
354 {
355 label: this.i18n('Unmute the instance by your instance'),
356 description: this.i18n('Show back content from that instance for you, your instance and its users.'),
357 isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
358 handler: ({ account }) => this.unblockServerByInstance(account.host)
359 }
360 ])
361 }
362
363 if (authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
364 instanceActions = instanceActions.concat([
365 {
366 label: this.i18n('Remove comments from your instance'),
367 description: this.i18n('Remove comments of this account from your instance.'),
368 handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'instance' })
369 }
370 ])
371 }
372
373 if (instanceActions.length !== 0) {
374 this.userActions.push(instanceActions)
375 }
376 }
377 }
378 }
379}
diff --git a/client/src/app/shared/shared-moderation/video-abuse.service.ts b/client/src/app/shared/shared-moderation/video-abuse.service.ts
new file mode 100644
index 000000000..44dea44a5
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-abuse.service.ts
@@ -0,0 +1,98 @@
1import { omit } from 'lodash-es'
2import { SortMeta } from 'primeng/api'
3import { Observable } from 'rxjs'
4import { catchError, map } from 'rxjs/operators'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { Injectable } from '@angular/core'
7import { RestExtractor, RestPagination, RestService } from '@app/core'
8import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models'
9import { environment } from '../../../environments/environment'
10
11@Injectable()
12export class VideoAbuseService {
13 private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restService: RestService,
18 private restExtractor: RestExtractor
19 ) {}
20
21 getVideoAbuses (options: {
22 pagination: RestPagination,
23 sort: SortMeta,
24 search?: string
25 }): Observable<ResultList<VideoAbuse>> {
26 const { pagination, sort, search } = options
27 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
28
29 let params = new HttpParams()
30 params = this.restService.addRestGetParams(params, pagination, sort)
31
32 if (search) {
33 const filters = this.restService.parseQueryStringFilter(search, {
34 id: { prefix: '#' },
35 state: {
36 prefix: 'state:',
37 handler: v => {
38 if (v === 'accepted') return VideoAbuseState.ACCEPTED
39 if (v === 'pending') return VideoAbuseState.PENDING
40 if (v === 'rejected') return VideoAbuseState.REJECTED
41
42 return undefined
43 }
44 },
45 videoIs: {
46 prefix: 'videoIs:',
47 handler: v => {
48 if (v === 'deleted') return v
49 if (v === 'blacklisted') return v
50
51 return undefined
52 }
53 },
54 searchReporter: { prefix: 'reporter:' },
55 searchReportee: { prefix: 'reportee:' },
56 predefinedReason: { prefix: 'tag:' }
57 })
58
59 params = this.restService.addObjectParams(params, filters)
60 }
61
62 return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
63 .pipe(
64 catchError(res => this.restExtractor.handleError(res))
65 )
66 }
67
68 reportVideo (parameters: { id: number } & VideoAbuseCreate) {
69 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
70
71 const body = omit(parameters, [ 'id' ])
72
73 return this.authHttp.post(url, body)
74 .pipe(
75 map(this.restExtractor.extractDataBool),
76 catchError(res => this.restExtractor.handleError(res))
77 )
78 }
79
80 updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
81 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
82
83 return this.authHttp.put(url, abuseUpdate)
84 .pipe(
85 map(this.restExtractor.extractDataBool),
86 catchError(res => this.restExtractor.handleError(res))
87 )
88 }
89
90 removeVideoAbuse (videoAbuse: VideoAbuse) {
91 const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
92
93 return this.authHttp.delete(url)
94 .pipe(
95 map(this.restExtractor.extractDataBool),
96 catchError(res => this.restExtractor.handleError(res))
97 )
98 }}
diff --git a/client/src/app/shared/shared-moderation/video-block.component.html b/client/src/app/shared/shared-moderation/video-block.component.html
new file mode 100644
index 000000000..5e73d66c5
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-block.component.html
@@ -0,0 +1,45 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8
9 <form novalidate [formGroup]="form" (ngSubmit)="block()">
10 <div class="form-group">
11 <textarea
12 i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
13 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
14 ></textarea>
15 <div *ngIf="formErrors.reason" class="form-error">
16 {{ formErrors.reason }}
17 </div>
18 </div>
19
20 <div class="form-group" *ngIf="video.isLocal">
21 <my-peertube-checkbox
22 inputName="unfederate" formControlName="unfederate"
23 i18n-labelText labelText="Unfederate the video"
24 >
25 <ng-container ngProjectAs="description">
26 <span i18n>This will ask remote instances to delete it</span>
27 </ng-container>
28 </my-peertube-checkbox>
29 </div>
30
31 <div class="form-group inputs">
32 <input
33 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
34 (click)="hide()" (key.enter)="hide()"
35 >
36
37 <input
38 type="submit" i18n-value value="Submit" class="action-button-submit"
39 [disabled]="!form.valid"
40 >
41 </div>
42 </form>
43
44 </div>
45</ng-template>
diff --git a/client/src/app/shared/shared-moderation/video-block.component.scss b/client/src/app/shared/shared-moderation/video-block.component.scss
new file mode 100644
index 000000000..afcdb9a16
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-block.component.scss
@@ -0,0 +1,6 @@
1@import 'variables';
2@import 'mixins';
3
4textarea {
5 @include peertube-textarea(100%, 100px);
6}
diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts
new file mode 100644
index 000000000..054651e71
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-block.component.ts
@@ -0,0 +1,74 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService, VideoBlockValidatorsService } from '@app/shared/shared-forms'
4import { Video } from '@app/shared/shared-main'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoBlockService } from './video-block.service'
9
10@Component({
11 selector: 'my-video-block',
12 templateUrl: './video-block.component.html',
13 styleUrls: [ './video-block.component.scss' ]
14})
15export class VideoBlockComponent extends FormReactive implements OnInit {
16 @Input() video: Video = null
17
18 @ViewChild('modal', { static: true }) modal: NgbModal
19
20 @Output() videoBlocked = new EventEmitter()
21
22 error: string = null
23
24 private openedModal: NgbModalRef
25
26 constructor (
27 protected formValidatorService: FormValidatorService,
28 private modalService: NgbModal,
29 private videoBlockValidatorsService: VideoBlockValidatorsService,
30 private videoBlocklistService: VideoBlockService,
31 private notifier: Notifier,
32 private i18n: I18n
33 ) {
34 super()
35 }
36
37 ngOnInit () {
38 const defaultValues = { unfederate: 'true' }
39
40 this.buildForm({
41 reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON,
42 unfederate: null
43 }, defaultValues)
44 }
45
46 show () {
47 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
48 }
49
50 hide () {
51 this.openedModal.close()
52 this.openedModal = null
53 }
54
55 block () {
56 const reason = this.form.value[ 'reason' ] || undefined
57 const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
58
59 this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate)
60 .subscribe(
61 () => {
62 this.notifier.success(this.i18n('Video blocked.'))
63 this.hide()
64
65 this.video.blacklisted = true
66 this.video.blockedReason = reason
67
68 this.videoBlocked.emit()
69 },
70
71 err => this.notifier.error(err.message)
72 )
73 }
74}
diff --git a/client/src/app/shared/shared-moderation/video-block.service.ts b/client/src/app/shared/shared-moderation/video-block.service.ts
new file mode 100644
index 000000000..c22ceefcc
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-block.service.ts
@@ -0,0 +1,78 @@
1import { SortMeta } from 'primeng/api'
2import { from as observableFrom, Observable } from 'rxjs'
3import { catchError, concatMap, map, toArray } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { ResultList, VideoBlacklist, VideoBlacklistType } from '@shared/models'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class VideoBlockService {
12 private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) {}
19
20 listBlocks (options: {
21 pagination: RestPagination
22 sort: SortMeta
23 search?: string
24 type?: VideoBlacklistType
25 }): Observable<ResultList<VideoBlacklist>> {
26 const { pagination, sort, search, type } = options
27
28 let params = new HttpParams()
29 params = this.restService.addRestGetParams(params, pagination, sort)
30
31 if (search) {
32 const filters = this.restService.parseQueryStringFilter(search, {
33 type: {
34 prefix: 'type:',
35 handler: v => {
36 if (v === 'manual') return VideoBlacklistType.MANUAL
37 if (v === 'auto') return VideoBlacklistType.AUTO_BEFORE_PUBLISHED
38
39 return undefined
40 }
41 }
42 })
43
44 params = this.restService.addObjectParams(params, filters)
45 }
46 if (type) params = params.append('type', type.toString())
47
48 return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params })
49 .pipe(
50 map(res => this.restExtractor.convertResultListDateToHuman(res)),
51 catchError(res => this.restExtractor.handleError(res))
52 )
53 }
54
55 unblockVideo (videoIdArgs: number | number[]) {
56 const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ]
57
58 return observableFrom(videoIds)
59 .pipe(
60 concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')),
61 toArray(),
62 catchError(err => this.restExtractor.handleError(err))
63 )
64 }
65
66 blockVideo (videoId: number, reason: string, unfederate: boolean) {
67 const body = {
68 unfederate,
69 reason
70 }
71
72 return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
73 .pipe(
74 map(this.restExtractor.extractDataBool),
75 catchError(res => this.restExtractor.handleError(res))
76 )
77 }
78}
diff --git a/client/src/app/shared/shared-moderation/video-report.component.html b/client/src/app/shared/shared-moderation/video-report.component.html
new file mode 100644
index 000000000..d6beb6d2a
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-report.component.html
@@ -0,0 +1,97 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8 <form novalidate [formGroup]="form" (ngSubmit)="report()">
9
10 <div class="row">
11 <div class="col-5 form-group">
12
13 <label i18n for="reportPredefinedReasons">What is the issue?</label>
14
15 <div class="ml-2 mt-2 d-flex flex-column">
16 <ng-container formGroupName="predefinedReasons">
17 <div class="form-group" *ngFor="let reason of predefinedReasons">
18 <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
19 <ng-template *ngIf="reason.help" ptTemplate="help">
20 <div [innerHTML]="reason.help"></div>
21 </ng-template>
22 <ng-container *ngIf="reason.description" ngProjectAs="description">
23 <div [innerHTML]="reason.description"></div>
24 </ng-container>
25 </my-peertube-checkbox>
26 </div>
27 </ng-container>
28 </div>
29
30 </div>
31
32 <div class="col-7">
33 <div class="row justify-content-center">
34 <div class="col-12 col-lg-9 mb-2">
35 <div class="screenratio">
36 <div [innerHTML]="embedHtml"></div>
37 </div>
38 </div>
39 </div>
40
41 <div class="mb-1 start-at" formGroupName="timestamp">
42 <my-peertube-checkbox
43 formControlName="hasStart"
44 i18n-labelText labelText="Start at"
45 ></my-peertube-checkbox>
46
47 <my-timestamp-input
48 [timestamp]="timestamp.startAt"
49 [maxTimestamp]="video.duration"
50 formControlName="startAt"
51 inputName="startAt"
52 >
53 </my-timestamp-input>
54 </div>
55
56 <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart">
57 <my-peertube-checkbox
58 formControlName="hasEnd"
59 i18n-labelText labelText="Stop at"
60 ></my-peertube-checkbox>
61
62 <my-timestamp-input
63 [timestamp]="timestamp.endAt"
64 [maxTimestamp]="video.duration"
65 formControlName="endAt"
66 inputName="endAt"
67 >
68 </my-timestamp-input>
69 </div>
70
71 <div i18n class="information">
72 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
73 </div>
74
75 <div class="form-group">
76 <textarea
77 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
78 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
79 ></textarea>
80 <div *ngIf="formErrors.reason" class="form-error">
81 {{ formErrors.reason }}
82 </div>
83 </div>
84 </div>
85 </div>
86
87 <div class="form-group inputs">
88 <input
89 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
90 (click)="hide()" (key.enter)="hide()"
91 >
92 <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
93 </div>
94
95 </form>
96 </div>
97</ng-template>
diff --git a/client/src/app/shared/shared-moderation/video-report.component.scss b/client/src/app/shared/shared-moderation/video-report.component.scss
new file mode 100644
index 000000000..b2606cbd8
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-report.component.scss
@@ -0,0 +1,27 @@
1@import 'variables';
2@import 'mixins';
3
4.information {
5 margin-bottom: 20px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 100px);
10}
11
12.start-at,
13.stop-at {
14 width: 300px;
15 display: flex;
16 align-items: center;
17
18 my-timestamp-input {
19 margin-left: 10px;
20 }
21}
22
23.screenratio {
24 @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
25 left: 0;
26 };
27}
diff --git a/client/src/app/shared/shared-moderation/video-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts
new file mode 100644
index 000000000..11c805636
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/video-report.component.ts
@@ -0,0 +1,161 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
3import { Component, Input, OnInit, ViewChild } from '@angular/core'
4import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
5import { Notifier } from '@app/core'
6import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { I18n } from '@ngx-translate/i18n-polyfill'
10import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model'
11import { Video } from '../shared-main'
12import { VideoAbuseService } from './video-abuse.service'
13
14@Component({
15 selector: 'my-video-report',
16 templateUrl: './video-report.component.html',
17 styleUrls: [ './video-report.component.scss' ]
18})
19export class VideoReportComponent extends FormReactive implements OnInit {
20 @Input() video: Video = null
21
22 @ViewChild('modal', { static: true }) modal: NgbModal
23
24 error: string = null
25 predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
26 embedHtml: SafeHtml
27
28 private openedModal: NgbModalRef
29
30 constructor (
31 protected formValidatorService: FormValidatorService,
32 private modalService: NgbModal,
33 private videoAbuseValidatorsService: VideoAbuseValidatorsService,
34 private videoAbuseService: VideoAbuseService,
35 private notifier: Notifier,
36 private sanitizer: DomSanitizer,
37 private i18n: I18n
38 ) {
39 super()
40 }
41
42 get currentHost () {
43 return window.location.host
44 }
45
46 get originHost () {
47 if (this.isRemoteVideo()) {
48 return this.video.account.host
49 }
50
51 return ''
52 }
53
54 get timestamp () {
55 return this.form.get('timestamp').value
56 }
57
58 getVideoEmbed () {
59 return this.sanitizer.bypassSecurityTrustHtml(
60 buildVideoEmbed(
61 buildVideoLink({
62 baseUrl: this.video.embedUrl,
63 title: false,
64 warningTitle: false
65 })
66 )
67 )
68 }
69
70 ngOnInit () {
71 this.buildForm({
72 reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
73 predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
74 timestamp: {
75 hasStart: null,
76 startAt: null,
77 hasEnd: null,
78 endAt: null
79 }
80 })
81
82 this.predefinedReasons = [
83 {
84 id: 'violentOrRepulsive',
85 label: this.i18n('Violent or repulsive'),
86 help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
87 },
88 {
89 id: 'hatefulOrAbusive',
90 label: this.i18n('Hateful or abusive'),
91 help: this.i18n('Contains abusive, racist or sexist language or iconography.')
92 },
93 {
94 id: 'spamOrMisleading',
95 label: this.i18n('Spam, ad or false news'),
96 help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
97 },
98 {
99 id: 'privacy',
100 label: this.i18n('Privacy breach or doxxing'),
101 help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
102 },
103 {
104 id: 'rights',
105 label: this.i18n('Intellectual property violation'),
106 help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
107 },
108 {
109 id: 'serverRules',
110 label: this.i18n('Breaks server rules'),
111 description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
112 },
113 {
114 id: 'thumbnails',
115 label: this.i18n('Thumbnails'),
116 help: this.i18n('The above can only be seen in thumbnails.')
117 },
118 {
119 id: 'captions',
120 label: this.i18n('Captions'),
121 help: this.i18n('The above can only be seen in captions (please describe which).')
122 }
123 ]
124
125 this.embedHtml = this.getVideoEmbed()
126 }
127
128 show () {
129 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
130 }
131
132 hide () {
133 this.openedModal.close()
134 this.openedModal = null
135 }
136
137 report () {
138 const reason = this.form.get('reason').value
139 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
140 const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
141
142 this.videoAbuseService.reportVideo({
143 id: this.video.id,
144 reason,
145 predefinedReasons,
146 startAt: hasStart && startAt ? startAt : undefined,
147 endAt: hasEnd && endAt ? endAt : undefined
148 }).subscribe(
149 () => {
150 this.notifier.success(this.i18n('Video reported.'))
151 this.hide()
152 },
153
154 err => this.notifier.error(err.message)
155 )
156 }
157
158 isRemoteVideo () {
159 return !this.video.isLocal
160 }
161}