aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/CONTRIBUTING.md35
-rw-r--r--client/src/app/+accounts/accounts.component.html5
-rw-r--r--client/src/app/+accounts/accounts.component.ts63
-rw-r--r--client/src/app/+admin/admin.component.ts12
-rw-r--r--client/src/app/+admin/admin.module.ts10
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.html115
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts (renamed from client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts)23
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.html184
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss (renamed from client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss)2
-rw-r--r--client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts454
-rw-r--r--client/src/app/+admin/moderation/abuse-list/index.ts3
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html)0
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss)0
-rw-r--r--client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts (renamed from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts)18
-rw-r--r--client/src/app/+admin/moderation/index.ts2
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss48
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts17
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/index.ts2
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html93
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html149
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts328
-rw-r--r--client/src/app/+admin/users/user-edit/user-edit.component.html8
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts3
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.html5
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts30
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.module.ts8
-rw-r--r--client/src/app/core/rest/rest-table.ts14
-rw-r--r--client/src/app/core/users/user.model.ts14
-rw-r--r--client/src/app/menu/menu.component.ts4
-rw-r--r--client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts (renamed from client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts)10
-rw-r--r--client/src/app/shared/shared-forms/form-validators/index.ts2
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts4
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts14
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts38
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html25
-rw-r--r--client/src/app/shared/shared-moderation/abuse.service.ts154
-rw-r--r--client/src/app/shared/shared-moderation/index.ts5
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/account-report.component.ts94
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts94
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/index.ts3
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/report.component.html62
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/report.component.scss (renamed from client/src/app/shared/shared-moderation/video-report.component.scss)0
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.html (renamed from client/src/app/shared/shared-moderation/video-report.component.html)9
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.ts122
-rw-r--r--client/src/app/shared/shared-moderation/shared-moderation.module.ts18
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts7
-rw-r--r--client/src/app/shared/shared-moderation/video-abuse.service.ts98
-rw-r--r--client/src/app/shared/shared-moderation/video-report.component.ts161
-rw-r--r--client/src/app/shared/shared-video-comment/index.ts5
-rw-r--r--client/src/app/shared/shared-video-comment/shared-video-comment.module.ts19
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts)0
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.model.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment.model.ts)0
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts (renamed from client/src/app/+videos/+video-watch/comment/video-comment.service.ts)2
-rw-r--r--server/controllers/api/abuse.ts168
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/users/my-history.ts2
-rw-r--r--server/controllers/api/users/my-notifications.ts2
-rw-r--r--server/controllers/api/videos/abuse.ts109
-rw-r--r--server/helpers/audit-logger.ts27
-rw-r--r--server/helpers/custom-validators/abuses.ts61
-rw-r--r--server/helpers/custom-validators/activitypub/flag.ts4
-rw-r--r--server/helpers/custom-validators/video-abuses.ts56
-rw-r--r--server/helpers/custom-validators/video-comments.ts81
-rw-r--r--server/helpers/middlewares/abuses.ts47
-rw-r--r--server/helpers/middlewares/accounts.ts4
-rw-r--r--server/helpers/middlewares/index.ts2
-rw-r--r--server/helpers/middlewares/video-abuses.ts32
-rw-r--r--server/initializers/constants.ts29
-rw-r--r--server/initializers/database.ts41
-rw-r--r--server/initializers/migrations/0250-video-abuse-state.ts4
-rw-r--r--server/initializers/migrations/0470-cleanup-indexes.ts (renamed from server/initializers/migrations/0470-cleaup-indexes.ts)0
-rw-r--r--server/initializers/migrations/0520-abuses-split.ts90
-rw-r--r--server/lib/activitypub/process/process-flag.ts117
-rw-r--r--server/lib/activitypub/send/send-flag.ts31
-rw-r--r--server/lib/activitypub/url.ts10
-rw-r--r--server/lib/emailer.ts114
-rw-r--r--server/lib/emails/account-abuse-new/html.pug14
-rw-r--r--server/lib/emails/common/mixins.pug6
-rw-r--r--server/lib/emails/video-abuse-new/html.pug8
-rw-r--r--server/lib/emails/video-comment-abuse-new/html.pug16
-rw-r--r--server/lib/moderation.ts164
-rw-r--r--server/lib/notifier.ts49
-rw-r--r--server/lib/user.ts2
-rw-r--r--server/middlewares/validators/abuse.ts277
-rw-r--r--server/middlewares/validators/index.ts1
-rw-r--r--server/middlewares/validators/sort.ts6
-rw-r--r--server/middlewares/validators/user-notifications.ts4
-rw-r--r--server/middlewares/validators/videos/index.ts1
-rw-r--r--server/middlewares/validators/videos/video-abuses.ts135
-rw-r--r--server/middlewares/validators/videos/video-comments.ts70
-rw-r--r--server/models/abuse/abuse-query-builder.ts154
-rw-r--r--server/models/abuse/abuse.ts515
-rw-r--r--server/models/abuse/video-abuse.ts63
-rw-r--r--server/models/abuse/video-comment-abuse.ts47
-rw-r--r--server/models/account/account-blocklist.ts10
-rw-r--r--server/models/account/account.ts9
-rw-r--r--server/models/account/user-notification-setting.ts8
-rw-r--r--server/models/account/user-notification.ts102
-rw-r--r--server/models/account/user.ts38
-rw-r--r--server/models/server/server-blocklist.ts10
-rw-r--r--server/models/video/video-abuse.ts479
-rw-r--r--server/models/video/video-channel.ts3
-rw-r--r--server/models/video/video-comment.ts27
-rw-r--r--server/models/video/video-query-builder.ts4
-rw-r--r--server/models/video/video.ts88
-rw-r--r--server/tests/api/check-params/abuses.ts269
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/user-notifications.ts2
-rw-r--r--server/tests/api/check-params/video-abuses.ts15
-rw-r--r--server/tests/api/ci-4.sh1
-rw-r--r--server/tests/api/index.ts1
-rw-r--r--server/tests/api/moderation/abuses.ts777
-rw-r--r--server/tests/api/moderation/blocklist.ts (renamed from server/tests/api/users/blocklist.ts)0
-rw-r--r--server/tests/api/moderation/index.ts2
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts85
-rw-r--r--server/tests/api/server/email.ts14
-rw-r--r--server/tests/api/users/index.ts3
-rw-r--r--server/tests/api/users/users.ts42
-rw-r--r--server/tests/api/videos/video-abuse.ts66
-rw-r--r--server/types/models/index.ts1
-rw-r--r--server/types/models/moderation/abuse.ts103
-rw-r--r--server/types/models/moderation/index.ts1
-rw-r--r--server/types/models/user/user-notification.ts32
-rw-r--r--server/types/models/video/index.ts1
-rw-r--r--server/types/models/video/video-abuse.ts35
-rw-r--r--server/typings/express/index.d.ts4
-rw-r--r--shared/extra-utils/index.ts1
-rw-r--r--shared/extra-utils/moderation/abuses.ts156
-rw-r--r--shared/extra-utils/server/servers.ts4
-rw-r--r--shared/extra-utils/users/user-notifications.ts83
-rw-r--r--shared/extra-utils/videos/video-abuses.ts18
-rw-r--r--shared/models/activitypub/activity.ts10
-rw-r--r--shared/models/activitypub/objects/abuse-object.ts (renamed from shared/models/activitypub/objects/video-abuse-object.ts)4
-rw-r--r--shared/models/activitypub/objects/common-objects.ts4
-rw-r--r--shared/models/activitypub/objects/index.ts4
-rw-r--r--shared/models/index.ts3
-rw-r--r--shared/models/moderation/abuse/abuse-create.model.ts29
-rw-r--r--shared/models/moderation/abuse/abuse-filter.type.ts1
-rw-r--r--shared/models/moderation/abuse/abuse-reason.model.ts33
-rw-r--r--shared/models/moderation/abuse/abuse-state.model.ts (renamed from shared/models/videos/abuse/video-abuse-state.model.ts)2
-rw-r--r--shared/models/moderation/abuse/abuse-update.model.ts7
-rw-r--r--shared/models/moderation/abuse/abuse-video-is.type.ts1
-rw-r--r--shared/models/moderation/abuse/abuse.model.ts73
-rw-r--r--shared/models/moderation/abuse/index.ts7
-rw-r--r--shared/models/moderation/account-block.model.ts (renamed from shared/models/blocklist/account-block.model.ts)0
-rw-r--r--shared/models/moderation/index.ts (renamed from shared/models/blocklist/index.ts)1
-rw-r--r--shared/models/moderation/server-block.model.ts (renamed from shared/models/blocklist/server-block.model.ts)0
-rw-r--r--shared/models/users/user-notification-setting.model.ts2
-rw-r--r--shared/models/users/user-notification.model.ts19
-rw-r--r--shared/models/users/user-right.enum.ts2
-rw-r--r--shared/models/users/user-role.ts2
-rw-r--r--shared/models/users/user.model.ts9
-rw-r--r--shared/models/videos/abuse/index.ts6
-rw-r--r--shared/models/videos/abuse/video-abuse-create.model.ts8
-rw-r--r--shared/models/videos/abuse/video-abuse-reason.model.ts33
-rw-r--r--shared/models/videos/abuse/video-abuse-update.model.ts6
-rw-r--r--shared/models/videos/abuse/video-abuse-video-is.type.ts1
-rw-r--r--shared/models/videos/abuse/video-abuse.model.ts38
-rw-r--r--shared/models/videos/index.ts1
-rw-r--r--support/doc/api/openapi.yaml154
162 files changed, 5663 insertions, 2460 deletions
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index b12e97361..704b22b8b 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -4,12 +4,26 @@ Interested in contributing? Awesome!
4 4
5**This guide will present you the following contribution topics:** 5**This guide will present you the following contribution topics:**
6 6
7 * [Translate](#translate) 7<!-- START doctoc generated TOC please keep comment here to allow auto update -->
8 * [Give your feedback](#give-your-feedback) 8<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
9 * [Write documentation](#write-documentation) 9
10 * [Improve the website](#improve-the-website) 10
11 * [Develop](#develop) 11- [Translate](#translate)
12 * [Write a plugin or a theme](#plugins--themes) 12- [Give your feedback](#give-your-feedback)
13- [Write documentation](#write-documentation)
14- [Improve the website](#improve-the-website)
15- [Develop](#develop)
16 - [Prerequisites](#prerequisites)
17 - [Online development](#online-development)
18 - [Server side](#server-side)
19 - [Client side](#client-side)
20 - [Client and server side](#client-and-server-side)
21 - [Testing the federation of PeerTube servers](#testing-the-federation-of-peertube-servers)
22 - [Unit tests](#unit-tests)
23 - [Emails](#emails)
24- [Plugins & Themes](#plugins--themes)
25
26<!-- END doctoc generated TOC please keep comment here to allow auto update -->
13 27
14## Translate 28## Translate
15 29
@@ -30,7 +44,7 @@ You can help to write the documentation of the REST API, code, architecture,
30demonstrations. 44demonstrations.
31 45
32For the REST API you can see the documentation in [/support/doc/api](https://github.com/Chocobozzz/PeerTube/tree/develop/support/doc/api) directory. 46For the REST API you can see the documentation in [/support/doc/api](https://github.com/Chocobozzz/PeerTube/tree/develop/support/doc/api) directory.
33Then, you can just open the `openapi.yaml` file in a special editor like [http://editor.swagger.io/](http://editor.swagger.io/) to easily see and edit the documentation. 47Then, you can just open the `openapi.yaml` file in a special editor like [http://editor.swagger.io/](http://editor.swagger.io/) to easily see and edit the documentation. You can also use [redoc-cli](https://github.com/Redocly/redoc/blob/master/cli/README.md) and run `redoc-cli serve --watch support/doc/api/openapi.yaml` to see the final result.
34 48
35Some hints: 49Some hints:
36 * Routes are defined in [/server/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/controllers) directory 50 * Routes are defined in [/server/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/controllers) directory
@@ -201,6 +215,13 @@ $ npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail
201Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`. 215Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
202Note that only instance 2 has transcoding enabled. 216Note that only instance 2 has transcoding enabled.
203 217
218### Emails
219
220To test emails with PeerTube:
221
222 * Run [mailslurper](http://mailslurper.com/)
223 * Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=test npm start`
224
204## Plugins & Themes 225## Plugins & Themes
205 226
206See the dedicated documentation: https://docs.joinpeertube.org/#/contribute-plugins 227See the dedicated documentation: https://docs.joinpeertube.org/#/contribute-plugins
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html
index af80337ce..31c8e3a8e 100644
--- a/client/src/app/+accounts/accounts.component.html
+++ b/client/src/app/+accounts/accounts.component.html
@@ -22,6 +22,7 @@
22 <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span> 22 <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span>
23 23
24 <my-user-moderation-dropdown 24 <my-user-moderation-dropdown
25 [prependActions]="prependModerationActions"
25 buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto" 26 buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto"
26 (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()" 27 (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
27 ></my-user-moderation-dropdown> 28 ></my-user-moderation-dropdown>
@@ -50,3 +51,7 @@
50 <router-outlet></router-outlet> 51 <router-outlet></router-outlet>
51 </div> 52 </div>
52</div> 53</div>
54
55<ng-container *ngIf="prependModerationActions">
56 <my-account-report #accountReportModal [account]="account"></my-account-report>
57</ng-container>
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts
index 01911cac2..9288fcb42 100644
--- a/client/src/app/+accounts/accounts.component.ts
+++ b/client/src/app/+accounts/accounts.component.ts
@@ -1,9 +1,10 @@
1import { Subscription } from 'rxjs' 1import { Subscription } from 'rxjs'
2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' 2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 4import { ActivatedRoute } from '@angular/router'
5import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' 5import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
6import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main' 6import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
7import { AccountReportComponent } from '@app/shared/shared-moderation'
7import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
8import { User, UserRight } from '@shared/models' 9import { User, UserRight } from '@shared/models'
9 10
@@ -12,6 +13,8 @@ import { User, UserRight } from '@shared/models'
12 styleUrls: [ './accounts.component.scss' ] 13 styleUrls: [ './accounts.component.scss' ]
13}) 14})
14export class AccountsComponent implements OnInit, OnDestroy { 15export class AccountsComponent implements OnInit, OnDestroy {
16 @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
17
15 account: Account 18 account: Account
16 accountUser: User 19 accountUser: User
17 videoChannels: VideoChannel[] = [] 20 videoChannels: VideoChannel[] = []
@@ -20,6 +23,8 @@ export class AccountsComponent implements OnInit, OnDestroy {
20 isAccountManageable = false 23 isAccountManageable = false
21 accountFollowerTitle = '' 24 accountFollowerTitle = ''
22 25
26 prependModerationActions: DropdownAction<any>[]
27
23 private routeSub: Subscription 28 private routeSub: Subscription
24 29
25 constructor ( 30 constructor (
@@ -42,24 +47,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
42 map(params => params[ 'accountId' ]), 47 map(params => params[ 'accountId' ]),
43 distinctUntilChanged(), 48 distinctUntilChanged(),
44 switchMap(accountId => this.accountService.getAccount(accountId)), 49 switchMap(accountId => this.accountService.getAccount(accountId)),
45 tap(account => { 50 tap(account => this.onAccount(account)),
46 this.account = account
47
48 if (this.authService.isLoggedIn()) {
49 this.authService.userInformationLoaded.subscribe(
50 () => {
51 this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
52
53 this.accountFollowerTitle = this.i18n(
54 '{{followers}} direct account followers',
55 { followers: this.subscribersDisplayFor(account.followersCount) }
56 )
57 }
58 )
59 }
60
61 this.getUserIfNeeded(account)
62 }),
63 switchMap(account => this.videoChannelService.listAccountVideoChannels(account)), 51 switchMap(account => this.videoChannelService.listAccountVideoChannels(account)),
64 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) 52 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
65 ) 53 )
@@ -107,6 +95,41 @@ export class AccountsComponent implements OnInit, OnDestroy {
107 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count }) 95 return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count })
108 } 96 }
109 97
98 private onAccount (account: Account) {
99 this.prependModerationActions = undefined
100
101 this.account = account
102
103 if (this.authService.isLoggedIn()) {
104 this.authService.userInformationLoaded.subscribe(
105 () => {
106 this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
107
108 this.accountFollowerTitle = this.i18n(
109 '{{followers}} direct account followers',
110 { followers: this.subscribersDisplayFor(account.followersCount) }
111 )
112
113 // It's not our account, we can report it
114 if (!this.isAccountManageable) {
115 this.prependModerationActions = [
116 {
117 label: this.i18n('Report account'),
118 handler: () => this.showReportModal()
119 }
120 ]
121 }
122 }
123 )
124 }
125
126 this.getUserIfNeeded(account)
127 }
128
129 private showReportModal () {
130 this.accountReportModal.show()
131 }
132
110 private getUserIfNeeded (account: Account) { 133 private getUserIfNeeded (account: Account) {
111 if (!account.userId || !this.authService.isLoggedIn()) return 134 if (!account.userId || !this.authService.isLoggedIn()) return
112 135
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 6f340884f..4345d1945 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -45,10 +45,10 @@ export class AdminComponent implements OnInit {
45 children: [] 45 children: []
46 } 46 }
47 47
48 if (this.hasVideoAbusesRight()) { 48 if (this.hasAbusesRight()) {
49 moderationItems.children.push({ 49 moderationItems.children.push({
50 label: this.i18n('Video reports'), 50 label: this.i18n('Reports'),
51 routerLink: '/admin/moderation/video-abuses/list', 51 routerLink: '/admin/moderation/abuses/list',
52 iconName: 'flag' 52 iconName: 'flag'
53 }) 53 })
54 } 54 }
@@ -76,7 +76,7 @@ export class AdminComponent implements OnInit {
76 76
77 if (this.hasUsersRight()) this.menuEntries.push({ label: this.i18n('Users'), routerLink: '/admin/users' }) 77 if (this.hasUsersRight()) this.menuEntries.push({ label: this.i18n('Users'), routerLink: '/admin/users' })
78 if (this.hasServerFollowRight()) this.menuEntries.push(federationItems) 78 if (this.hasServerFollowRight()) this.menuEntries.push(federationItems)
79 if (this.hasVideoAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems) 79 if (this.hasAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems)
80 if (this.hasConfigRight()) this.menuEntries.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' }) 80 if (this.hasConfigRight()) this.menuEntries.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' })
81 if (this.hasPluginsRight()) this.menuEntries.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' }) 81 if (this.hasPluginsRight()) this.menuEntries.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' })
82 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.menuEntries.push({ label: this.i18n('System'), routerLink: '/admin/system' }) 82 if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.menuEntries.push({ label: this.i18n('System'), routerLink: '/admin/system' })
@@ -90,8 +90,8 @@ export class AdminComponent implements OnInit {
90 return this.auth.getUser().hasRight(UserRight.MANAGE_SERVER_FOLLOW) 90 return this.auth.getUser().hasRight(UserRight.MANAGE_SERVER_FOLLOW)
91 } 91 }
92 92
93 hasVideoAbusesRight () { 93 hasAbusesRight () {
94 return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) 94 return this.auth.getUser().hasRight(UserRight.MANAGE_ABUSES)
95 } 95 }
96 96
97 hasVideoBlocklistRight () { 97 hasVideoBlocklistRight () {
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index 728227a84..c59bd2927 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -14,10 +14,10 @@ import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponen
14import { FollowingListComponent } from './follows/following-list/following-list.component' 14import { FollowingListComponent } from './follows/following-list/following-list.component'
15import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' 15import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
16import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 16import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
17import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlockListComponent } from './moderation' 17import { ModerationCommentModalComponent, AbuseListComponent, VideoBlockListComponent } from './moderation'
18import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' 18import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
19import { ModerationComponent } from './moderation/moderation.component' 19import { ModerationComponent } from './moderation/moderation.component'
20import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component' 20import { AbuseDetailsComponent } from './moderation/abuse-list/abuse-details.component'
21import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component' 21import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
22import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component' 22import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
23import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component' 23import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
@@ -60,8 +60,10 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
60 60
61 ModerationComponent, 61 ModerationComponent,
62 VideoBlockListComponent, 62 VideoBlockListComponent,
63 VideoAbuseListComponent, 63
64 VideoAbuseDetailsComponent, 64 AbuseListComponent,
65 AbuseDetailsComponent,
66
65 ModerationCommentModalComponent, 67 ModerationCommentModalComponent,
66 InstanceServerBlocklistComponent, 68 InstanceServerBlocklistComponent,
67 InstanceAccountBlocklistComponent, 69 InstanceAccountBlocklistComponent,
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
new file mode 100644
index 000000000..cba9cfb73
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html
@@ -0,0 +1,115 @@
1<div class="d-flex moderation-expanded">
2 <!-- report left part (report details) -->
3 <div class="col-8">
4
5 <!-- report metadata -->
6 <div class="d-flex" *ngIf="abuse.reporterAccount">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8
9 <span class="col-9 moderation-expanded-text">
10 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
11 class="chip"
12 >
13 <img
14 class="avatar"
15 [src]="abuse.reporterAccount.avatar?.path"
16 (error)="switchToDefaultAvatar($event)"
17 alt="Avatar"
18 >
19 <div>
20 <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
21 </div>
22 </a>
23
24 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
25 class="ml-auto text-muted abuse-details-links" i18n
26 >
27 {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
28 </a>
29 </span>
30 </div>
31
32 <div class="d-flex" *ngIf="abuse.flaggedAccount">
33 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
34 <span class="col-9 moderation-expanded-text">
35 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
36 class="chip"
37 >
38 <img
39 class="avatar"
40 [src]="abuse.flaggedAccount?.avatar?.path"
41 (error)="switchToDefaultAvatar($event)"
42 alt="Avatar"
43 >
44 <div>
45 <span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
46 </div>
47 </a>
48
49 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
50 class="ml-auto text-muted abuse-details-links" i18n
51 >
52 {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
53 </a>
54 </span>
55 </div>
56
57 <div class="d-flex" *ngIf="abuse.updatedAt">
58 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
59 <time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
60 </div>
61
62 <!-- report text -->
63 <div class="mt-3 d-flex">
64 <span class="col-3 moderation-expanded-label">
65 <ng-container i18n>Report</ng-container>
66 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': '#' + abuse.id }" class="ml-1 text-muted">#{{ abuse.id }}</a>
67 </span>
68 <span class="col-9 moderation-expanded-text" [innerHTML]="abuse.reasonHtml"></span>
69 </div>
70
71 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
72 <span class="col-3"></span>
73 <span class="col-9">
74 <a *ngFor="let reason of getPredefinedReasons()" [routerLink]="[ '/admin/moderation/abuses/list' ]"
75 [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light"
76 >
77 <div>{{ reason.label }}</div>
78 </a>
79 </span>
80 </div>
81
82 <div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
83 <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
84 <span class="col-9">
85 {{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
86 </span>
87 </div>
88
89 <div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
90 <span class="col-3 moderation-expanded-label" i18n>Note</span>
91 <span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
92 </div>
93
94 </div>
95
96 <!-- report right part (video/comment details) -->
97 <div class="col-4">
98 <div *ngIf="abuse.video" class="screenratio">
99 <div>
100 <span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
101 <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
102 </div>
103
104 <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
105 </div>
106
107 <div *ngIf="abuse.comment" class="comment-html">
108 <div>
109 <strong i18n>Comment:</strong>
110 </div>
111
112 <div [innerHTML]="abuse.commentHtml"></div>
113 </div>
114 </div>
115</div>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
index 5db2887fa..fb0f65764 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
@@ -1,19 +1,19 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Actor } from '@app/shared/shared-main' 2import { Actor } from '@app/shared/shared-main'
3import { I18n } from '@ngx-translate/i18n-polyfill' 3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' 4import { AbusePredefinedReasonsString } from '@shared/models'
5import { ProcessedVideoAbuse } from './video-abuse-list.component' 5import { ProcessedAbuse } from './abuse-list.component'
6import { durationToString } from '@app/helpers' 6import { durationToString } from '@app/helpers'
7 7
8@Component({ 8@Component({
9 selector: 'my-video-abuse-details', 9 selector: 'my-abuse-details',
10 templateUrl: './video-abuse-details.component.html', 10 templateUrl: './abuse-details.component.html',
11 styleUrls: [ '../moderation.component.scss' ] 11 styleUrls: [ '../moderation.component.scss' ]
12}) 12})
13export class VideoAbuseDetailsComponent { 13export class AbuseDetailsComponent {
14 @Input() videoAbuse: ProcessedVideoAbuse 14 @Input() abuse: ProcessedAbuse
15 15
16 private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string } 16 private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }
17 17
18 constructor ( 18 constructor (
19 private i18n: I18n 19 private i18n: I18n
@@ -31,16 +31,17 @@ export class VideoAbuseDetailsComponent {
31 } 31 }
32 32
33 get startAt () { 33 get startAt () {
34 return durationToString(this.videoAbuse.startAt) 34 return durationToString(this.abuse.video.startAt)
35 } 35 }
36 36
37 get endAt () { 37 get endAt () {
38 return durationToString(this.videoAbuse.endAt) 38 return durationToString(this.abuse.video.endAt)
39 } 39 }
40 40
41 getPredefinedReasons () { 41 getPredefinedReasons () {
42 if (!this.videoAbuse.predefinedReasons) return [] 42 if (!this.abuse.predefinedReasons) return []
43 return this.videoAbuse.predefinedReasons.map(r => ({ 43
44 return this.abuse.predefinedReasons.map(r => ({
44 id: r, 45 id: r,
45 label: this.predefinedReasonsTranslations[r] 46 label: this.predefinedReasonsTranslations[r]
46 })) 47 }))
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
new file mode 100644
index 000000000..99502304d
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html
@@ -0,0 +1,184 @@
1<p-table
2 [value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" [lazyLoadOnInit]="false"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <div class="input-group has-feedback has-clear">
12 <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
13 <div class="input-group-text" ngbDropdownToggle>
14 <span class="caret" aria-haspopup="menu" role="button"></span>
15 </div>
16
17 <div role="menu" ngbDropdownMenu>
18 <h6 class="dropdown-header" i18n>Advanced report filters</h6>
19 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
20 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
21 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
22 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
23 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
24 </div>
25 </div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onAbuseSearch($event)"
29 >
30 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
31 <span class="sr-only" i18n>Clear filters</span>
32 </div>
33 </div>
34 </div>
35 </ng-template>
36
37 <ng-template pTemplate="header">
38 <tr> <!-- header -->
39 <th style="width: 40px;"></th>
40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
41 <th i18n>Video/Comment/Account</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
44 <th style="width: 150px;"></th>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="body" let-expanded="expanded" let-abuse>
49 <tr>
50 <td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
51 <span class="expander">
52 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
53 </span>
54 </td>
55
56 <td>
57 <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
58 <div class="chip two-lines">
59 <img
60 class="avatar"
61 [src]="abuse.reporterAccount.avatar?.path"
62 (error)="switchToDefaultAvatar($event)"
63 alt="Avatar"
64 >
65 <div>
66 {{ abuse.reporterAccount.displayName }}
67 <span>{{ abuse.reporterAccount.nameWithHost }}</span>
68 </div>
69 </div>
70 </a>
71
72 <span i18n *ngIf="!abuse.reporterAccount">
73 Deleted account
74 </span>
75 </td>
76
77 <ng-container *ngIf="abuse.video">
78
79 <td *ngIf="!abuse.video.deleted">
80 <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
81 <div class="table-video">
82 <div class="table-video-image">
83 <img [src]="abuse.video.thumbnailPath">
84 <span
85 class="table-video-image-label" *ngIf="abuse.count > 1"
86 i18n-title title="This video has been reported multiple times."
87 >
88 {{ abuse.nth }}/{{ abuse.count }}
89 </span>
90 </div>
91
92 <div class="table-video-text">
93 <div>
94 <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
95 <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
96 {{ abuse.video.name }}
97 </div>
98 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
99 </div>
100 </div>
101 </a>
102 </td>
103
104 <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
105 <div class="table-video" i18n-title title="Video was deleted">
106 <div class="table-video-image">
107 <span i18n>Deleted</span>
108 </div>
109
110 <div class="table-video-text">
111 <div>
112 {{ abuse.video.name }}
113 <span class="glyphicon glyphicon-trash"></span>
114 </div>
115 <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
116 </div>
117 </div>
118 </td>
119 </ng-container>
120
121 <ng-container *ngIf="abuse.comment">
122 <td>
123 <a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
124 [title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
125 ></a>
126
127 <div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
128 </td>
129 </ng-container>
130
131 <ng-container *ngIf="!abuse.comment && !abuse.video">
132 <td *ngIf="abuse.flaggedAccount">
133 <a [href]="getAccountUrl(abuse)" class="table-account-link" target="_blank" rel="noopener noreferrer">
134 <span>{{ abuse.flaggedAccount.displayName }}</span>
135
136 <span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
137 </a>
138 </td>
139
140 <td i18n *ngIf="!abuse.flaggedAccount">
141 Account deleted
142 </td>
143
144 </ng-container>
145
146
147 <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
148
149 <td class="c-hand abuse-states" [pRowToggler]="abuse">
150 <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
151 <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
152 <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
153 </td>
154
155 <td class="action-cell">
156 <my-action-dropdown
157 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
158 i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
159 ></my-action-dropdown>
160 </td>
161 </tr>
162 </ng-template>
163
164 <ng-template pTemplate="rowexpansion" let-abuse>
165 <tr>
166 <td class="expand-cell" colspan="6">
167 <my-abuse-details [abuse]="abuse"></my-abuse-details>
168 </td>
169 </tr>
170 </ng-template>
171
172 <ng-template pTemplate="emptymessage">
173 <tr>
174 <td colspan="6">
175 <div class="no-results">
176 <ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
177 <ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
178 </div>
179 </td>
180 </tr>
181 </ng-template>
182</p-table>
183
184<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss
index 8eee15b64..c22f98c47 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss
@@ -10,7 +10,7 @@
10 @include disable-default-a-behaviour; 10 @include disable-default-a-behaviour;
11} 11}
12 12
13.video-abuse-states .glyphicon-comment { 13.abuse-states .glyphicon-comment {
14 margin-left: 0.5rem; 14 margin-left: 0.5rem;
15} 15}
16 16
diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
new file mode 100644
index 000000000..74c5fe2b3
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts
@@ -0,0 +1,454 @@
1import * as debug from 'debug'
2import truncate from 'lodash-es/truncate'
3import { SortMeta } from 'primeng/api'
4import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
5import { environment } from 'src/environments/environment'
6import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
7import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
8import { ActivatedRoute, Params, Router } from '@angular/router'
9import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
10import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
11import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
12import { VideoCommentService } from '@app/shared/shared-video-comment'
13import { I18n } from '@ngx-translate/i18n-polyfill'
14import { Abuse, AbuseState } from '@shared/models'
15import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
16
17const logger = debug('peertube:moderation:AbuseListComponent')
18
19// Don't use an abuse model because we need external services to compute some properties
20// And this model is only used in this component
21export type ProcessedAbuse = Abuse & {
22 moderationCommentHtml?: string,
23 reasonHtml?: string
24 embedHtml?: SafeHtml
25 updatedAt?: Date
26
27 // override bare server-side definitions with rich client-side definitions
28 reporterAccount?: Account
29 flaggedAccount?: Account
30
31 truncatedCommentHtml?: string
32 commentHtml?: string
33
34 video: Abuse['video'] & {
35 channel: Abuse['video']['channel'] & {
36 ownerAccount: Account
37 }
38 }
39}
40
41@Component({
42 selector: 'my-abuse-list',
43 templateUrl: './abuse-list.component.html',
44 styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ]
45})
46export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
47 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
48
49 abuses: ProcessedAbuse[] = []
50 totalRecords = 0
51 sort: SortMeta = { field: 'createdAt', order: 1 }
52 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
53
54 abuseActions: DropdownAction<ProcessedAbuse>[][] = []
55
56 constructor (
57 private notifier: Notifier,
58 private abuseService: AbuseService,
59 private blocklistService: BlocklistService,
60 private commentService: VideoCommentService,
61 private videoService: VideoService,
62 private videoBlocklistService: VideoBlockService,
63 private confirmService: ConfirmService,
64 private i18n: I18n,
65 private markdownRenderer: MarkdownService,
66 private sanitizer: DomSanitizer,
67 private route: ActivatedRoute,
68 private router: Router
69 ) {
70 super()
71
72 this.abuseActions = [
73 this.buildInternalActions(),
74
75 this.buildFlaggedAccountActions(),
76
77 this.buildCommentActions(),
78
79 this.buildVideoActions(),
80
81 this.buildAccountActions()
82 ]
83 }
84
85 ngOnInit () {
86 this.initialize()
87
88 this.route.queryParams
89 .subscribe(params => {
90 this.search = params.search || ''
91
92 logger('On URL change (search: %s).', this.search)
93
94 this.setTableFilter(this.search)
95 this.loadData()
96 })
97 }
98
99 ngAfterViewInit () {
100 if (this.search) this.setTableFilter(this.search)
101 }
102
103 getIdentifier () {
104 return 'AbuseListComponent'
105 }
106
107 openModerationCommentModal (abuse: Abuse) {
108 this.moderationCommentModal.openModal(abuse)
109 }
110
111 onModerationCommentUpdated () {
112 this.loadData()
113 }
114
115 /* Table filter functions */
116 onAbuseSearch (event: Event) {
117 this.onSearch(event)
118 this.setQueryParams((event.target as HTMLInputElement).value)
119 }
120
121 setQueryParams (search: string) {
122 const queryParams: Params = {}
123 if (search) Object.assign(queryParams, { search })
124
125 this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
126 }
127
128 resetTableFilter () {
129 this.setTableFilter('')
130 this.setQueryParams('')
131 this.resetSearch()
132 }
133 /* END Table filter functions */
134
135 isAbuseAccepted (abuse: Abuse) {
136 return abuse.state.id === AbuseState.ACCEPTED
137 }
138
139 isAbuseRejected (abuse: Abuse) {
140 return abuse.state.id === AbuseState.REJECTED
141 }
142
143 getVideoUrl (abuse: Abuse) {
144 return Video.buildClientUrl(abuse.video.uuid)
145 }
146
147 getCommentUrl (abuse: Abuse) {
148 return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
149 }
150
151 getAccountUrl (abuse: ProcessedAbuse) {
152 return '/accounts/' + abuse.flaggedAccount.nameWithHost
153 }
154
155 getVideoEmbed (abuse: Abuse) {
156 return buildVideoEmbed(
157 buildVideoLink({
158 baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
159 title: false,
160 warningTitle: false,
161 startTime: abuse.startAt,
162 stopTime: abuse.endAt
163 })
164 )
165 }
166
167 switchToDefaultAvatar ($event: Event) {
168 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
169 }
170
171 async removeAbuse (abuse: Abuse) {
172 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
173 if (res === false) return
174
175 this.abuseService.removeAbuse(abuse).subscribe(
176 () => {
177 this.notifier.success(this.i18n('Abuse deleted.'))
178 this.loadData()
179 },
180
181 err => this.notifier.error(err.message)
182 )
183 }
184
185 updateAbuseState (abuse: Abuse, state: AbuseState) {
186 this.abuseService.updateAbuse(abuse, { state })
187 .subscribe(
188 () => this.loadData(),
189
190 err => this.notifier.error(err.message)
191 )
192 }
193
194 protected loadData () {
195 logger('Load data.')
196
197 return this.abuseService.getAbuses({
198 pagination: this.pagination,
199 sort: this.sort,
200 search: this.search
201 }).subscribe(
202 async resultList => {
203 this.totalRecords = resultList.total
204
205 this.abuses = []
206
207 for (const a of resultList.data) {
208 const abuse = a as ProcessedAbuse
209
210 abuse.reasonHtml = await this.toHtml(abuse.reason)
211 abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
212
213 if (abuse.video) {
214 abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
215
216 if (abuse.video.channel?.ownerAccount) {
217 abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
218 }
219 }
220
221 if (abuse.comment) {
222 if (abuse.comment.deleted) {
223 abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
224 } else {
225 const truncated = truncate(abuse.comment.text, { length: 100 })
226 abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
227 abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
228 }
229 }
230
231 if (abuse.reporterAccount) {
232 abuse.reporterAccount = new Account(abuse.reporterAccount)
233 }
234
235 if (abuse.flaggedAccount) {
236 abuse.flaggedAccount = new Account(abuse.flaggedAccount)
237 }
238
239 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
240
241 this.abuses.push(abuse)
242 }
243 },
244
245 err => this.notifier.error(err.message)
246 )
247 }
248
249 private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
250 return [
251 {
252 label: this.i18n('Internal actions'),
253 isHeader: true
254 },
255 {
256 label: this.i18n('Delete report'),
257 handler: abuse => this.removeAbuse(abuse)
258 },
259 {
260 label: this.i18n('Add note'),
261 handler: abuse => this.openModerationCommentModal(abuse),
262 isDisplayed: abuse => !abuse.moderationComment
263 },
264 {
265 label: this.i18n('Update note'),
266 handler: abuse => this.openModerationCommentModal(abuse),
267 isDisplayed: abuse => !!abuse.moderationComment
268 },
269 {
270 label: this.i18n('Mark as accepted'),
271 handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
272 isDisplayed: abuse => !this.isAbuseAccepted(abuse)
273 },
274 {
275 label: this.i18n('Mark as rejected'),
276 handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
277 isDisplayed: abuse => !this.isAbuseRejected(abuse)
278 }
279 ]
280 }
281
282 private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
283 return [
284 {
285 label: this.i18n('Actions for the flagged account'),
286 isHeader: true,
287 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
288 },
289
290 {
291 label: this.i18n('Mute account'),
292 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
293 handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
294 },
295
296 {
297 label: this.i18n('Mute server account'),
298 isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
299 handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
300 }
301 ]
302 }
303
304 private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
305 return [
306 {
307 label: this.i18n('Actions for the reporter'),
308 isHeader: true,
309 isDisplayed: abuse => !!abuse.reporterAccount
310 },
311
312 {
313 label: this.i18n('Mute reporter'),
314 isDisplayed: abuse => !!abuse.reporterAccount,
315 handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
316 },
317
318 {
319 label: this.i18n('Mute server'),
320 isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
321 handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
322 }
323 ]
324 }
325
326 private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
327 return [
328 {
329 label: this.i18n('Actions for the video'),
330 isHeader: true,
331 isDisplayed: abuse => abuse.video && !abuse.video.deleted
332 },
333 {
334 label: this.i18n('Block video'),
335 isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
336 handler: abuse => {
337 this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
338 .subscribe(
339 () => {
340 this.notifier.success(this.i18n('Video blocked.'))
341
342 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
343 },
344
345 err => this.notifier.error(err.message)
346 )
347 }
348 },
349 {
350 label: this.i18n('Unblock video'),
351 isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
352 handler: abuse => {
353 this.videoBlocklistService.unblockVideo(abuse.video.id)
354 .subscribe(
355 () => {
356 this.notifier.success(this.i18n('Video unblocked.'))
357
358 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
359 },
360
361 err => this.notifier.error(err.message)
362 )
363 }
364 },
365 {
366 label: this.i18n('Delete video'),
367 isDisplayed: abuse => abuse.video && !abuse.video.deleted,
368 handler: async abuse => {
369 const res = await this.confirmService.confirm(
370 this.i18n('Do you really want to delete this video?'),
371 this.i18n('Delete')
372 )
373 if (res === false) return
374
375 this.videoService.removeVideo(abuse.video.id)
376 .subscribe(
377 () => {
378 this.notifier.success(this.i18n('Video deleted.'))
379
380 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
381 },
382
383 err => this.notifier.error(err.message)
384 )
385 }
386 }
387 ]
388 }
389
390 private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
391 return [
392 {
393 label: this.i18n('Actions for the comment'),
394 isHeader: true,
395 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
396 },
397
398 {
399 label: this.i18n('Delete comment'),
400 isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
401 handler: async abuse => {
402 const res = await this.confirmService.confirm(
403 this.i18n('Do you really want to delete this comment?'),
404 this.i18n('Delete')
405 )
406 if (res === false) return
407
408 this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
409 .subscribe(
410 () => {
411 this.notifier.success(this.i18n('Comment deleted.'))
412
413 this.updateAbuseState(abuse, AbuseState.ACCEPTED)
414 },
415
416 err => this.notifier.error(err.message)
417 )
418 }
419 }
420 ]
421 }
422
423 private muteAccountHelper (account: Account) {
424 this.blocklistService.blockAccountByInstance(account)
425 .subscribe(
426 () => {
427 this.notifier.success(
428 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
429 )
430
431 account.mutedByInstance = true
432 },
433
434 err => this.notifier.error(err.message)
435 )
436 }
437
438 private muteServerHelper (host: string) {
439 this.blocklistService.blockServerByInstance(host)
440 .subscribe(
441 () => {
442 this.notifier.success(
443 this.i18n('Server {{host}} muted by the instance.', { host: host })
444 )
445 },
446
447 err => this.notifier.error(err.message)
448 )
449 }
450
451 private toHtml (text: string) {
452 return this.markdownRenderer.textMarkdownToHTML(text)
453 }
454}
diff --git a/client/src/app/+admin/moderation/abuse-list/index.ts b/client/src/app/+admin/moderation/abuse-list/index.ts
new file mode 100644
index 000000000..c6037dab4
--- /dev/null
+++ b/client/src/app/+admin/moderation/abuse-list/index.ts
@@ -0,0 +1,3 @@
1export * from './abuse-details.component'
2export * from './abuse-list.component'
3export * from './moderation-comment-modal.component'
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html
index 8082e93f4..8082e93f4 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss
index afcdb9a16..afcdb9a16 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts
index 3cd763ca4..23738f9cd 100644
--- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts
@@ -1,11 +1,11 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms' 3import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
4import { VideoAbuseService } from '@app/shared/shared-moderation' 4import { AbuseService } from '@app/shared/shared-moderation'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 5import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoAbuse } from '@shared/models' 8import { Abuse } from '@shared/models'
9 9
10@Component({ 10@Component({
11 selector: 'my-moderation-comment-modal', 11 selector: 'my-moderation-comment-modal',
@@ -16,15 +16,15 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
16 @ViewChild('modal', { static: true }) modal: NgbModal 16 @ViewChild('modal', { static: true }) modal: NgbModal
17 @Output() commentUpdated = new EventEmitter<string>() 17 @Output() commentUpdated = new EventEmitter<string>()
18 18
19 private abuseToComment: VideoAbuse 19 private abuseToComment: Abuse
20 private openedModal: NgbModalRef 20 private openedModal: NgbModalRef
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formValidatorService: FormValidatorService,
24 private modalService: NgbModal, 24 private modalService: NgbModal,
25 private notifier: Notifier, 25 private notifier: Notifier,
26 private videoAbuseService: VideoAbuseService, 26 private abuseService: AbuseService,
27 private videoAbuseValidatorsService: VideoAbuseValidatorsService, 27 private abuseValidatorsService: AbuseValidatorsService,
28 private i18n: I18n 28 private i18n: I18n
29 ) { 29 ) {
30 super() 30 super()
@@ -32,11 +32,11 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
32 32
33 ngOnInit () { 33 ngOnInit () {
34 this.buildForm({ 34 this.buildForm({
35 moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_MODERATION_COMMENT 35 moderationComment: this.abuseValidatorsService.ABUSE_MODERATION_COMMENT
36 }) 36 })
37 } 37 }
38 38
39 openModal (abuseToComment: VideoAbuse) { 39 openModal (abuseToComment: Abuse) {
40 this.abuseToComment = abuseToComment 40 this.abuseToComment = abuseToComment
41 this.openedModal = this.modalService.open(this.modal, { centered: true }) 41 this.openedModal = this.modalService.open(this.modal, { centered: true })
42 42
@@ -54,7 +54,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
54 async banUser () { 54 async banUser () {
55 const moderationComment: string = this.form.value[ 'moderationComment' ] 55 const moderationComment: string = this.form.value[ 'moderationComment' ]
56 56
57 this.videoAbuseService.updateVideoAbuse(this.abuseToComment, { moderationComment }) 57 this.abuseService.updateAbuse(this.abuseToComment, { moderationComment })
58 .subscribe( 58 .subscribe(
59 () => { 59 () => {
60 this.notifier.success(this.i18n('Comment updated.')) 60 this.notifier.success(this.i18n('Comment updated.'))
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts
index 16249236c..53e4bc991 100644
--- a/client/src/app/+admin/moderation/index.ts
+++ b/client/src/app/+admin/moderation/index.ts
@@ -1,5 +1,5 @@
1export * from './abuse-list'
1export * from './instance-blocklist' 2export * from './instance-blocklist'
2export * from './video-abuse-list'
3export * from './video-block-list' 3export * from './video-block-list'
4export * from './moderation.component' 4export * from './moderation.component'
5export * from './moderation.routes' 5export * from './moderation.routes'
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss
index 0ec420af9..65fe94d39 100644
--- a/client/src/app/+admin/moderation/moderation.component.scss
+++ b/client/src/app/+admin/moderation/moderation.component.scss
@@ -25,18 +25,18 @@
25 vertical-align: top; 25 vertical-align: top;
26 text-align: right; 26 text-align: right;
27 } 27 }
28 28
29 .moderation-expanded-text { 29 .moderation-expanded-text {
30 display: inline-flex; 30 display: inline-flex;
31 word-wrap: break-word; 31 word-wrap: break-word;
32 32
33 ::ng-deep p:last-child { 33 ::ng-deep p:last-child {
34 margin-bottom: 0px !important; 34 margin-bottom: 0px !important;
35 } 35 }
36 } 36 }
37} 37}
38 38
39.video-table-states { 39.table-states {
40 & > :not(:first-child) { 40 & > :not(:first-child) {
41 margin-left: .4rem; 41 margin-left: .4rem;
42 } 42 }
@@ -59,6 +59,7 @@ p-calendar {
59.screenratio { 59.screenratio {
60 div { 60 div {
61 @include miniature-thumbnail; 61 @include miniature-thumbnail;
62
62 display: inline-flex; 63 display: inline-flex;
63 justify-content: center; 64 justify-content: center;
64 align-items: center; 65 align-items: center;
@@ -72,6 +73,11 @@ p-calendar {
72 }; 73 };
73} 74}
74 75
76.comment-html {
77 background-color: #ececec;
78 padding: 10px;
79}
80
75.chip { 81.chip {
76 @include chip; 82 @include chip;
77} 83}
@@ -83,16 +89,39 @@ my-action-dropdown.show {
83} 89}
84 90
85 91
86.video-table-video-link { 92.table-video-link {
87 @include disable-outline; 93 @include disable-outline;
94
88 position: relative; 95 position: relative;
89 top: 3px; 96 top: 3px;
90} 97}
91 98
92.video-table-video { 99.table-comment-link,
100.table-account-link {
101 @include disable-outline;
102
103 color: var(--mainForegroundColor);
104
105 ::ng-deep p:last-child {
106 margin: 0;
107 }
108}
109
110.table-account-link {
111 display: flex;
112 flex-direction: column;
113}
114
115.comment-flagged-account,
116.account-flagged-handle {
117 font-size: 11px;
118 color: var(--greyForegroundColor);
119}
120
121.table-video {
93 display: inline-flex; 122 display: inline-flex;
94 123
95 .video-table-video-image { 124 .table-video-image {
96 @include miniature-thumbnail; 125 @include miniature-thumbnail;
97 126
98 $image-height: 45px; 127 $image-height: 45px;
@@ -118,7 +147,7 @@ my-action-dropdown.show {
118 color: pvar(--inputPlaceholderColor); 147 color: pvar(--inputPlaceholderColor);
119 } 148 }
120 149
121 .video-table-video-image-label { 150 .table-video-image-label {
122 @include static-thumbnail-overlay; 151 @include static-thumbnail-overlay;
123 position: absolute; 152 position: absolute;
124 border-radius: 3px; 153 border-radius: 3px;
@@ -130,7 +159,7 @@ my-action-dropdown.show {
130 } 159 }
131 } 160 }
132 161
133 .video-table-video-text { 162 .table-video-text {
134 display: inline-flex; 163 display: inline-flex;
135 flex-direction: column; 164 flex-direction: column;
136 justify-content: center; 165 justify-content: center;
@@ -145,7 +174,8 @@ my-action-dropdown.show {
145 } 174 }
146 175
147 div + div { 176 div + div {
148 font-size: 80%; 177 color: var(--greyForegroundColor);
178 font-size: 11px;
149 } 179 }
150 } 180 }
151} 181}
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index cd837bcb9..8a31a54dc 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -1,7 +1,7 @@
1import { Routes } from '@angular/router' 1import { Routes } from '@angular/router'
2import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' 2import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
3import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 3import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
4import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list' 4import { AbuseListComponent } from '@app/+admin/moderation/abuse-list'
5import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list' 5import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
6import { UserRightGuard } from '@app/core' 6import { UserRightGuard } from '@app/core'
7import { UserRight } from '@shared/models' 7import { UserRight } from '@shared/models'
@@ -13,22 +13,27 @@ export const ModerationRoutes: Routes = [
13 children: [ 13 children: [
14 { 14 {
15 path: '', 15 path: '',
16 redirectTo: 'video-abuses/list', 16 redirectTo: 'abuses/list',
17 pathMatch: 'full' 17 pathMatch: 'full'
18 }, 18 },
19 { 19 {
20 path: 'video-abuses', 20 path: 'video-abuses',
21 redirectTo: 'video-abuses/list', 21 redirectTo: 'abuses/list',
22 pathMatch: 'full' 22 pathMatch: 'full'
23 }, 23 },
24 { 24 {
25 path: 'video-abuses/list', 25 path: 'video-abuses/list',
26 component: VideoAbuseListComponent, 26 redirectTo: 'abuses/list',
27 pathMatch: 'full'
28 },
29 {
30 path: 'abuses/list',
31 component: AbuseListComponent,
27 canActivate: [ UserRightGuard ], 32 canActivate: [ UserRightGuard ],
28 data: { 33 data: {
29 userRight: UserRight.MANAGE_VIDEO_ABUSES, 34 userRight: UserRight.MANAGE_ABUSES,
30 meta: { 35 meta: {
31 title: 'Video reports' 36 title: 'Reports'
32 } 37 }
33 } 38 }
34 }, 39 },
diff --git a/client/src/app/+admin/moderation/video-abuse-list/index.ts b/client/src/app/+admin/moderation/video-abuse-list/index.ts
deleted file mode 100644
index da7176e52..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './video-abuse-list.component'
2export * from './moderation-comment-modal.component'
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
deleted file mode 100644
index ec808cdb8..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html
+++ /dev/null
@@ -1,93 +0,0 @@
1<div class="d-flex moderation-expanded">
2 <!-- report left part (report details) -->
3 <div class="col-8">
4
5 <!-- report metadata -->
6 <div class="d-flex">
7 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
8 <span class="col-9 moderation-expanded-text">
9 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
10 <img
11 class="avatar"
12 [src]="videoAbuse.reporterAccount.avatar?.path"
13 (error)="switchToDefaultAvatar($event)"
14 alt="Avatar"
15 >
16 <div>
17 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
18 </div>
19 </a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
21 {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
22 </a>
23 </span>
24 </div>
25
26 <div class="d-flex">
27 <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
28 <span class="col-9 moderation-expanded-text">
29 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
30 <img
31 class="avatar"
32 [src]="videoAbuse.video.channel.ownerAccount?.avatar?.path"
33 (error)="switchToDefaultAvatar($event)"
34 alt="Avatar"
35 >
36 <div>
37 <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
38 </div>
39 </a>
40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
41 {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
42 </a>
43 </span>
44 </div>
45
46 <div class="d-flex" *ngIf="videoAbuse.updatedAt">
47 <span class="col-3 moderation-expanded-label" i18n>Updated</span>
48 <time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time>
49 </div>
50
51 <!-- report text -->
52 <div class="mt-3 d-flex">
53 <span class="col-3 moderation-expanded-label">
54 <ng-container i18n>Report</ng-container>
55 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a>
56 </span>
57 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
58 </div>
59
60 <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
61 <span class="col-3"></span>
62 <span class="col-9">
63 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
64 <div>{{ reason.label }}</div>
65 </a>
66 </span>
67 </div>
68
69 <div *ngIf="videoAbuse.startAt" class="mt-2 d-flex">
70 <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
71 <span class="col-9">
72 {{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container>
73 </span>
74 </div>
75
76 <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
77 <span class="col-3 moderation-expanded-label" i18n>Note</span>
78 <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
79 </div>
80
81 </div>
82
83 <!-- report right part (video details) -->
84 <div class="col-4">
85 <div class="screenratio">
86 <div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted">
87 <span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span>
88 <span i18n *ngIf="!videoAbuse.video.deleted">The video was blocked</span>
89 </div>
90 <div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div>
91 </div>
92 </div>
93</div>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
deleted file mode 100644
index 64641b28a..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
+++ /dev/null
@@ -1,149 +0,0 @@
1<p-table
2 [value]="videoAbuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
3 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
4 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
5 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
6 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
7>
8 <ng-template pTemplate="caption">
9 <div class="caption">
10 <div class="ml-auto">
11 <div class="input-group has-feedback has-clear">
12 <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
13 <div class="input-group-text" ngbDropdownToggle>
14 <span class="caret" aria-haspopup="menu" role="button"></span>
15 </div>
16
17 <div role="menu" ngbDropdownMenu>
18 <h6 class="dropdown-header" i18n>Advanced report filters</h6>
19 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
20 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
21 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
22 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
23 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
24 </div>
25 </div>
26 <input
27 type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
28 (keyup)="onAbuseSearch($event)"
29 >
30 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
31 <span class="sr-only" i18n>Clear filters</span>
32 </div>
33 </div>
34 </div>
35 </ng-template>
36
37 <ng-template pTemplate="header">
38 <tr> <!-- header -->
39 <th style="width: 40px;"></th>
40 <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
41 <th i18n>Video</th>
42 <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
43 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
44 <th style="width: 150px;"></th>
45 </tr>
46 </ng-template>
47
48 <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse>
49 <tr>
50 <td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
51 <span class="expander">
52 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
53 </span>
54 </td>
55
56 <td>
57 <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
58 <div class="chip two-lines">
59 <img
60 class="avatar"
61 [src]="videoAbuse.reporterAccount.avatar?.path"
62 (error)="switchToDefaultAvatar($event)"
63 alt="Avatar"
64 >
65 <div>
66 {{ videoAbuse.reporterAccount.displayName }}
67 <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
68 </div>
69 </div>
70 </a>
71 </td>
72
73 <td *ngIf="!videoAbuse.video.deleted">
74 <a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" [title]="videoAbuse.video.name" target="_blank" rel="noopener noreferrer">
75 <div class="video-table-video">
76 <div class="video-table-video-image">
77 <img [src]="videoAbuse.video.thumbnailPath">
78 <span
79 class="video-table-video-image-label" *ngIf="videoAbuse.count > 1"
80 i18n-title title="This video has been reported multiple times."
81 >
82 {{ videoAbuse.nth }}/{{ videoAbuse.count }}
83 </span>
84 </div>
85 <div class="video-table-video-text">
86 <div>
87 <span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
88 <span *ngIf="videoAbuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
89 {{ videoAbuse.video.name }}
90 </div>
91 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
92 </div>
93 </div>
94 </a>
95 </td>
96
97 <td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse">
98 <div class="video-table-video" i18n-title title="Video was deleted">
99 <div class="video-table-video-image">
100 <span i18n>Deleted</span>
101 </div>
102 <div class="video-table-video-text">
103 <div>
104 {{ videoAbuse.video.name }}
105 <span class="glyphicon glyphicon-trash"></span>
106 </div>
107 <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
108 </div>
109 </div>
110 </td>
111
112 <td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short' }}</td>
113
114 <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse">
115 <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span>
116 <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span>
117 <span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span>
118 </td>
119
120 <td class="action-cell">
121 <my-action-dropdown
122 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
123 i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
124 ></my-action-dropdown>
125 </td>
126 </tr>
127 </ng-template>
128
129 <ng-template pTemplate="rowexpansion" let-videoAbuse>
130 <tr>
131 <td class="expand-cell" colspan="6">
132 <my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details>
133 </td>
134 </tr>
135 </ng-template>
136
137 <ng-template pTemplate="emptymessage">
138 <tr>
139 <td colspan="6">
140 <div class="no-results">
141 <ng-container *ngIf="search" i18n>No video abuses found matching current filters.</ng-container>
142 <ng-container *ngIf="!search" i18n>No video abuses found.</ng-container>
143 </div>
144 </td>
145 </tr>
146 </ng-template>
147</p-table>
148
149<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
deleted file mode 100644
index 409dd42c7..000000000
--- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts
+++ /dev/null
@@ -1,328 +0,0 @@
1import { SortMeta } from 'primeng/api'
2import { filter } from 'rxjs/operators'
3import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
4import { environment } from 'src/environments/environment'
5import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
6import { DomSanitizer } from '@angular/platform-browser'
7import { ActivatedRoute, Params, Router } from '@angular/router'
8import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
9import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation'
11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { VideoAbuse, VideoAbuseState } from '@shared/models'
13import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
14
15export type ProcessedVideoAbuse = VideoAbuse & {
16 moderationCommentHtml?: string,
17 reasonHtml?: string
18 embedHtml?: string
19 updatedAt?: Date
20 // override bare server-side definitions with rich client-side definitions
21 reporterAccount: Account
22 video: VideoAbuse['video'] & {
23 channel: VideoAbuse['video']['channel'] & {
24 ownerAccount: Account
25 }
26 }
27}
28
29@Component({
30 selector: 'my-video-abuse-list',
31 templateUrl: './video-abuse-list.component.html',
32 styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ]
33})
34export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit {
35 @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
36
37 videoAbuses: ProcessedVideoAbuse[] = []
38 totalRecords = 0
39 sort: SortMeta = { field: 'createdAt', order: 1 }
40 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
41
42 videoAbuseActions: DropdownAction<VideoAbuse>[][] = []
43
44 constructor (
45 private notifier: Notifier,
46 private videoAbuseService: VideoAbuseService,
47 private blocklistService: BlocklistService,
48 private videoService: VideoService,
49 private videoBlocklistService: VideoBlockService,
50 private confirmService: ConfirmService,
51 private i18n: I18n,
52 private markdownRenderer: MarkdownService,
53 private sanitizer: DomSanitizer,
54 private route: ActivatedRoute,
55 private router: Router
56 ) {
57 super()
58
59 this.videoAbuseActions = [
60 [
61 {
62 label: this.i18n('Internal actions'),
63 isHeader: true
64 },
65 {
66 label: this.i18n('Delete report'),
67 handler: videoAbuse => this.removeVideoAbuse(videoAbuse)
68 },
69 {
70 label: this.i18n('Add note'),
71 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
72 isDisplayed: videoAbuse => !videoAbuse.moderationComment
73 },
74 {
75 label: this.i18n('Update note'),
76 handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
77 isDisplayed: videoAbuse => !!videoAbuse.moderationComment
78 },
79 {
80 label: this.i18n('Mark as accepted'),
81 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED),
82 isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse)
83 },
84 {
85 label: this.i18n('Mark as rejected'),
86 handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED),
87 isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse)
88 }
89 ],
90 [
91 {
92 label: this.i18n('Actions for the video'),
93 isHeader: true,
94 isDisplayed: videoAbuse => !videoAbuse.video.deleted
95 },
96 {
97 label: this.i18n('Block video'),
98 isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted,
99 handler: videoAbuse => {
100 this.videoBlocklistService.blockVideo(videoAbuse.video.id, undefined, true)
101 .subscribe(
102 () => {
103 this.notifier.success(this.i18n('Video blocked.'))
104
105 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
106 },
107
108 err => this.notifier.error(err.message)
109 )
110 }
111 },
112 {
113 label: this.i18n('Unblock video'),
114 isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted,
115 handler: videoAbuse => {
116 this.videoBlocklistService.unblockVideo(videoAbuse.video.id)
117 .subscribe(
118 () => {
119 this.notifier.success(this.i18n('Video unblocked.'))
120
121 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
122 },
123
124 err => this.notifier.error(err.message)
125 )
126 }
127 },
128 {
129 label: this.i18n('Delete video'),
130 isDisplayed: videoAbuse => !videoAbuse.video.deleted,
131 handler: async videoAbuse => {
132 const res = await this.confirmService.confirm(
133 this.i18n('Do you really want to delete this video?'),
134 this.i18n('Delete')
135 )
136 if (res === false) return
137
138 this.videoService.removeVideo(videoAbuse.video.id)
139 .subscribe(
140 () => {
141 this.notifier.success(this.i18n('Video deleted.'))
142
143 this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
144 },
145
146 err => this.notifier.error(err.message)
147 )
148 }
149 }
150 ],
151 [
152 {
153 label: this.i18n('Actions for the reporter'),
154 isHeader: true
155 },
156 {
157 label: this.i18n('Mute reporter'),
158 handler: async videoAbuse => {
159 const account = videoAbuse.reporterAccount as Account
160
161 this.blocklistService.blockAccountByInstance(account)
162 .subscribe(
163 () => {
164 this.notifier.success(
165 this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
166 )
167
168 account.mutedByInstance = true
169 },
170
171 err => this.notifier.error(err.message)
172 )
173 }
174 },
175 {
176 label: this.i18n('Mute server'),
177 isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId,
178 handler: async videoAbuse => {
179 this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host)
180 .subscribe(
181 () => {
182 this.notifier.success(
183 this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host })
184 )
185 },
186
187 err => this.notifier.error(err.message)
188 )
189 }
190 }
191 ]
192 ]
193 }
194
195 ngOnInit () {
196 this.initialize()
197
198 this.route.queryParams
199 .subscribe(params => {
200 this.search = params.search || ''
201
202 this.setTableFilter(this.search)
203 this.loadData()
204 })
205 }
206
207 ngAfterViewInit () {
208 if (this.search) this.setTableFilter(this.search)
209 }
210
211 getIdentifier () {
212 return 'VideoAbuseListComponent'
213 }
214
215 openModerationCommentModal (videoAbuse: VideoAbuse) {
216 this.moderationCommentModal.openModal(videoAbuse)
217 }
218
219 onModerationCommentUpdated () {
220 this.loadData()
221 }
222
223 /* Table filter functions */
224 onAbuseSearch (event: Event) {
225 this.onSearch(event)
226 this.setQueryParams((event.target as HTMLInputElement).value)
227 }
228
229 setQueryParams (search: string) {
230 const queryParams: Params = {}
231 if (search) Object.assign(queryParams, { search })
232
233 this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams })
234 }
235
236 resetTableFilter () {
237 this.setTableFilter('')
238 this.setQueryParams('')
239 this.resetSearch()
240 }
241 /* END Table filter functions */
242
243 isVideoAbuseAccepted (videoAbuse: VideoAbuse) {
244 return videoAbuse.state.id === VideoAbuseState.ACCEPTED
245 }
246
247 isVideoAbuseRejected (videoAbuse: VideoAbuse) {
248 return videoAbuse.state.id === VideoAbuseState.REJECTED
249 }
250
251 getVideoUrl (videoAbuse: VideoAbuse) {
252 return Video.buildClientUrl(videoAbuse.video.uuid)
253 }
254
255 getVideoEmbed (videoAbuse: VideoAbuse) {
256 return buildVideoEmbed(
257 buildVideoLink({
258 baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`,
259 title: false,
260 warningTitle: false,
261 startTime: videoAbuse.startAt,
262 stopTime: videoAbuse.endAt
263 })
264 )
265 }
266
267 switchToDefaultAvatar ($event: Event) {
268 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
269 }
270
271 async removeVideoAbuse (videoAbuse: VideoAbuse) {
272 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
273 if (res === false) return
274
275 this.videoAbuseService.removeVideoAbuse(videoAbuse).subscribe(
276 () => {
277 this.notifier.success(this.i18n('Abuse deleted.'))
278 this.loadData()
279 },
280
281 err => this.notifier.error(err.message)
282 )
283 }
284
285 updateVideoAbuseState (videoAbuse: VideoAbuse, state: VideoAbuseState) {
286 this.videoAbuseService.updateVideoAbuse(videoAbuse, { state })
287 .subscribe(
288 () => this.loadData(),
289
290 err => this.notifier.error(err.message)
291 )
292 }
293
294 protected loadData () {
295 return this.videoAbuseService.getVideoAbuses({
296 pagination: this.pagination,
297 sort: this.sort,
298 search: this.search
299 }).subscribe(
300 async resultList => {
301 this.totalRecords = resultList.total
302 const videoAbuses = []
303
304 for (const abuse of resultList.data) {
305 Object.assign(abuse, {
306 reasonHtml: await this.toHtml(abuse.reason),
307 moderationCommentHtml: await this.toHtml(abuse.moderationComment),
308 embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
309 reporterAccount: new Account(abuse.reporterAccount)
310 })
311
312 if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
313 if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
314
315 videoAbuses.push(abuse as ProcessedVideoAbuse)
316 }
317
318 this.videoAbuses = videoAbuses
319 },
320
321 err => this.notifier.error(err.message)
322 )
323 }
324
325 private toHtml (text: string) {
326 return this.markdownRenderer.textMarkdownToHTML(text)
327 }
328}
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html
index 297e6104c..2e7b322ca 100644
--- a/client/src/app/+admin/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/users/user-edit/user-edit.component.html
@@ -37,14 +37,14 @@
37 </a> 37 </a>
38 </div> 38 </div>
39 <div> 39 <div>
40 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }"> 40 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }">
41 <div class="dashboard-num">{{ user.videoAbusesCount }}</div> 41 <div class="dashboard-num">{{ user.abusesCount }}</div>
42 <div class="dashboard-label" i18n>Incriminated in reports</div> 42 <div class="dashboard-label" i18n>Incriminated in reports</div>
43 </a> 43 </a>
44 </div> 44 </div>
45 <div> 45 <div>
46 <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }"> 46 <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }">
47 <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div> 47 <div class="dashboard-num">{{ user.abusesAcceptedCount }} / {{ user.abusesCreatedCount }}</div>
48 <div class="dashboard-label" i18n>Authored reports accepted</div> 48 <div class="dashboard-label" i18n>Authored reports accepted</div>
49 </a> 49 </a>
50 </div> 50 </div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
index cfa514b26..8562e564b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
@@ -33,7 +33,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
33 this.labelNotifications = { 33 this.labelNotifications = {
34 newVideoFromSubscription: this.i18n('New video from your subscriptions'), 34 newVideoFromSubscription: this.i18n('New video from your subscriptions'),
35 newCommentOnMyVideo: this.i18n('New comment on your video'), 35 newCommentOnMyVideo: this.i18n('New comment on your video'),
36 videoAbuseAsModerator: this.i18n('New video abuse'), 36 abuseAsModerator: this.i18n('New abuse'),
37 videoAutoBlacklistAsModerator: this.i18n('Video blocked automatically waiting review'), 37 videoAutoBlacklistAsModerator: this.i18n('Video blocked automatically waiting review'),
38 blacklistOnMyVideo: this.i18n('One of your video is blocked/unblocked'), 38 blacklistOnMyVideo: this.i18n('One of your video is blocked/unblocked'),
39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), 39 myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
@@ -47,7 +47,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
47 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] 47 this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
48 48
49 this.rightNotifications = { 49 this.rightNotifications = {
50 videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, 50 abuseAsModerator: UserRight.MANAGE_ABUSES,
51 videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST, 51 videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST,
52 newUserRegistration: UserRight.MANAGE_USERS, 52 newUserRegistration: UserRight.MANAGE_USERS,
53 newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW, 53 newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW,
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
index 79505c779..d79efbb49 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
@@ -4,10 +4,9 @@ import { Router } from '@angular/router'
4import { Notifier, User } from '@app/core' 4import { Notifier, User } from '@app/core'
5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
6import { Video } from '@app/shared/shared-main' 6import { Video } from '@app/shared/shared-main'
7import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 8import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { VideoCommentCreate } from '@shared/models' 9import { VideoCommentCreate } from '@shared/models'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11 10
12@Component({ 11@Component({
13 selector: 'my-video-comment-add', 12 selector: 'my-video-comment-add',
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
index 002de57e4..f02ea549a 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
@@ -45,6 +45,7 @@
45 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div> 45 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
46 46
47 <my-user-moderation-dropdown 47 <my-user-moderation-dropdown
48 [prependActions]="prependModerationActions"
48 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto" 49 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
49 ></my-user-moderation-dropdown> 50 ></my-user-moderation-dropdown>
50 </div> 51 </div>
@@ -93,3 +94,7 @@
93 </div> 94 </div>
94 </div> 95 </div>
95</div> 96</div>
97
98<ng-container *ngIf="prependModerationActions">
99 <my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
100</ng-container>
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
index 27846c1ad..6744a0954 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
@@ -1,10 +1,12 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' 1
2import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
2import { MarkdownService, Notifier, UserService } from '@app/core' 3import { MarkdownService, Notifier, UserService } from '@app/core'
3import { AuthService } from '@app/core/auth' 4import { AuthService } from '@app/core/auth'
4import { Account, Actor, Video } from '@app/shared/shared-main' 5import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
6import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
7import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
8import { I18n } from '@ngx-translate/i18n-polyfill'
5import { User, UserRight } from '@shared/models' 9import { User, UserRight } from '@shared/models'
6import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
7import { VideoComment } from './video-comment.model'
8 10
9@Component({ 11@Component({
10 selector: 'my-video-comment', 12 selector: 'my-video-comment',
@@ -12,6 +14,8 @@ import { VideoComment } from './video-comment.model'
12 styleUrls: ['./video-comment.component.scss'] 14 styleUrls: ['./video-comment.component.scss']
13}) 15})
14export class VideoCommentComponent implements OnInit, OnChanges { 16export class VideoCommentComponent implements OnInit, OnChanges {
17 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
18
15 @Input() video: Video 19 @Input() video: Video
16 @Input() comment: VideoComment 20 @Input() comment: VideoComment
17 @Input() parentComments: VideoComment[] = [] 21 @Input() parentComments: VideoComment[] = []
@@ -26,6 +30,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
26 @Output() resetReply = new EventEmitter() 30 @Output() resetReply = new EventEmitter()
27 @Output() timestampClicked = new EventEmitter<number>() 31 @Output() timestampClicked = new EventEmitter<number>()
28 32
33 prependModerationActions: DropdownAction<any>[]
34
29 sanitizedCommentHTML = '' 35 sanitizedCommentHTML = ''
30 newParentComments: VideoComment[] = [] 36 newParentComments: VideoComment[] = []
31 37
@@ -33,6 +39,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
33 commentUser: User 39 commentUser: User
34 40
35 constructor ( 41 constructor (
42 private i18n: I18n,
36 private markdownService: MarkdownService, 43 private markdownService: MarkdownService,
37 private authService: AuthService, 44 private authService: AuthService,
38 private userService: UserService, 45 private userService: UserService,
@@ -127,5 +134,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
127 } else { 134 } else {
128 this.comment.account = null 135 this.comment.account = null
129 } 136 }
137
138 if (this.isUserLoggedIn() && this.authService.getUser().account.id !== this.comment.account.id) {
139 this.prependModerationActions = [
140 {
141 label: this.i18n('Report comment'),
142 handler: () => this.showReportModal()
143 }
144 ]
145 } else {
146 this.prependModerationActions = undefined
147 }
148 }
149
150 private showReportModal () {
151 this.commentReportModal.show()
130 } 152 }
131} 153}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
index df0018ec6..66494a20a 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
@@ -4,10 +4,8 @@ import { ActivatedRoute } from '@angular/router'
4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' 4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service' 5import { HooksService } from '@app/core/plugins/hooks.service'
6import { Syndication, VideoDetails } from '@app/shared/shared-main' 6import { Syndication, VideoDetails } from '@app/shared/shared-main'
7import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
7import { I18n } from '@ngx-translate/i18n-polyfill' 8import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11 9
12@Component({ 10@Component({
13 selector: 'my-video-comments', 11 selector: 'my-video-comments',
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts
index 421170d81..5821dc2b7 100644
--- a/client/src/app/+videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.module.ts
@@ -5,16 +5,17 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
5import { SharedMainModule } from '@app/shared/shared-main' 5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedModerationModule } from '@app/shared/shared-moderation' 6import { SharedModerationModule } from '@app/shared/shared-moderation'
7import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' 7import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
8import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
8import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 9import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
9import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' 10import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
10import { RecommendationsModule } from './recommendations/recommendations.module'
11import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' 11import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
12import { VideoCommentService } from '../../shared/shared-video-comment/video-comment.service'
12import { VideoCommentAddComponent } from './comment/video-comment-add.component' 13import { VideoCommentAddComponent } from './comment/video-comment-add.component'
13import { VideoCommentComponent } from './comment/video-comment.component' 14import { VideoCommentComponent } from './comment/video-comment.component'
14import { VideoCommentService } from './comment/video-comment.service'
15import { VideoCommentsComponent } from './comment/video-comments.component' 15import { VideoCommentsComponent } from './comment/video-comments.component'
16import { VideoShareComponent } from './modal/video-share.component' 16import { VideoShareComponent } from './modal/video-share.component'
17import { VideoSupportComponent } from './modal/video-support.component' 17import { VideoSupportComponent } from './modal/video-support.component'
18import { RecommendationsModule } from './recommendations/recommendations.module'
18import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' 19import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
19import { VideoDurationPipe } from './video-duration-formatter.pipe' 20import { VideoDurationPipe } from './video-duration-formatter.pipe'
20import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' 21import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
@@ -34,7 +35,8 @@ import { VideoWatchComponent } from './video-watch.component'
34 SharedVideoPlaylistModule, 35 SharedVideoPlaylistModule,
35 SharedUserSubscriptionModule, 36 SharedUserSubscriptionModule,
36 SharedModerationModule, 37 SharedModerationModule,
37 SharedGlobalIconModule 38 SharedGlobalIconModule,
39 SharedVideoCommentModule
38 ], 40 ],
39 41
40 declarations: [ 42 declarations: [
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index 1b35ad47d..e6328eddc 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -3,6 +3,9 @@ import { LazyLoadEvent, SortMeta } from 'primeng/api'
3import { RestPagination } from './rest-pagination' 3import { RestPagination } from './rest-pagination'
4import { Subject } from 'rxjs' 4import { Subject } from 'rxjs'
5import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 5import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
6import * as debug from 'debug'
7
8const logger = debug('peertube:tables:RestTable')
6 9
7export abstract class RestTable { 10export abstract class RestTable {
8 11
@@ -15,7 +18,7 @@ export abstract class RestTable {
15 rowsPerPage = this.rowsPerPageOptions[0] 18 rowsPerPage = this.rowsPerPageOptions[0]
16 expandedRows = {} 19 expandedRows = {}
17 20
18 private searchStream: Subject<string> 21 protected searchStream: Subject<string>
19 22
20 abstract getIdentifier (): string 23 abstract getIdentifier (): string
21 24
@@ -37,6 +40,8 @@ export abstract class RestTable {
37 } 40 }
38 41
39 loadLazy (event: LazyLoadEvent) { 42 loadLazy (event: LazyLoadEvent) {
43 logger('Load lazy %o.', event)
44
40 this.sort = { 45 this.sort = {
41 order: event.sortOrder, 46 order: event.sortOrder,
42 field: event.sortField 47 field: event.sortField
@@ -65,6 +70,9 @@ export abstract class RestTable {
65 ) 70 )
66 .subscribe(search => { 71 .subscribe(search => {
67 this.search = search 72 this.search = search
73
74 logger('On search %s.', this.search)
75
68 this.loadData() 76 this.loadData()
69 }) 77 })
70 } 78 }
@@ -75,14 +83,18 @@ export abstract class RestTable {
75 } 83 }
76 84
77 onPage (event: { first: number, rows: number }) { 85 onPage (event: { first: number, rows: number }) {
86 logger('On page %o.', event)
87
78 if (this.rowsPerPage !== event.rows) { 88 if (this.rowsPerPage !== event.rows) {
79 this.rowsPerPage = event.rows 89 this.rowsPerPage = event.rows
80 this.pagination = { 90 this.pagination = {
81 start: event.first, 91 start: event.first,
82 count: this.rowsPerPage 92 count: this.rowsPerPage
83 } 93 }
94
84 this.loadData() 95 this.loadData()
85 } 96 }
97
86 this.expandedRows = {} 98 this.expandedRows = {}
87 } 99 }
88 100
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index 8ecdf9fcd..31b9c2152 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -51,12 +51,14 @@ export class User implements UserServerModel {
51 videoQuotaDaily: number 51 videoQuotaDaily: number
52 videoQuotaUsed?: number 52 videoQuotaUsed?: number
53 videoQuotaUsedDaily?: number 53 videoQuotaUsedDaily?: number
54
54 videosCount?: number 55 videosCount?: number
55 videoAbusesCount?: number
56 videoAbusesAcceptedCount?: number
57 videoAbusesCreatedCount?: number
58 videoCommentsCount?: number 56 videoCommentsCount?: number
59 57
58 abusesCount?: number
59 abusesAcceptedCount?: number
60 abusesCreatedCount?: number
61
60 theme: string 62 theme: string
61 63
62 account: Account 64 account: Account
@@ -89,9 +91,9 @@ export class User implements UserServerModel {
89 this.videoQuotaUsed = hash.videoQuotaUsed 91 this.videoQuotaUsed = hash.videoQuotaUsed
90 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily 92 this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
91 this.videosCount = hash.videosCount 93 this.videosCount = hash.videosCount
92 this.videoAbusesCount = hash.videoAbusesCount 94 this.abusesCount = hash.abusesCount
93 this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount 95 this.abusesAcceptedCount = hash.abusesAcceptedCount
94 this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount 96 this.abusesCreatedCount = hash.abusesCreatedCount
95 this.videoCommentsCount = hash.videoCommentsCount 97 this.videoCommentsCount = hash.videoCommentsCount
96 98
97 this.nsfwPolicy = hash.nsfwPolicy 99 this.nsfwPolicy = hash.nsfwPolicy
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 2dbe695c9..0ea251f1c 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -28,7 +28,7 @@ export class MenuComponent implements OnInit {
28 private routesPerRight: { [ role in UserRight ]?: string } = { 28 private routesPerRight: { [ role in UserRight ]?: string } = {
29 [UserRight.MANAGE_USERS]: '/admin/users', 29 [UserRight.MANAGE_USERS]: '/admin/users',
30 [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends', 30 [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends',
31 [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/moderation/video-abuses', 31 [UserRight.MANAGE_ABUSES]: '/admin/moderation/abuses',
32 [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks', 32 [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks',
33 [UserRight.MANAGE_JOBS]: '/admin/jobs', 33 [UserRight.MANAGE_JOBS]: '/admin/jobs',
34 [UserRight.MANAGE_CONFIGURATION]: '/admin/config' 34 [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
@@ -126,7 +126,7 @@ export class MenuComponent implements OnInit {
126 const adminRights = [ 126 const adminRights = [
127 UserRight.MANAGE_USERS, 127 UserRight.MANAGE_USERS,
128 UserRight.MANAGE_SERVER_FOLLOW, 128 UserRight.MANAGE_SERVER_FOLLOW,
129 UserRight.MANAGE_VIDEO_ABUSES, 129 UserRight.MANAGE_ABUSES,
130 UserRight.MANAGE_VIDEO_BLACKLIST, 130 UserRight.MANAGE_VIDEO_BLACKLIST,
131 UserRight.MANAGE_JOBS, 131 UserRight.MANAGE_JOBS,
132 UserRight.MANAGE_CONFIGURATION 132 UserRight.MANAGE_CONFIGURATION
diff --git a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts
index aae56d607..739115e19 100644
--- a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts
+++ b/client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts
@@ -4,12 +4,12 @@ import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service' 4import { BuildFormValidator } from './form-validator.service'
5 5
6@Injectable() 6@Injectable()
7export class VideoAbuseValidatorsService { 7export class AbuseValidatorsService {
8 readonly VIDEO_ABUSE_REASON: BuildFormValidator 8 readonly ABUSE_REASON: BuildFormValidator
9 readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator 9 readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
10 10
11 constructor (private i18n: I18n) { 11 constructor (private i18n: I18n) {
12 this.VIDEO_ABUSE_REASON = { 12 this.ABUSE_REASON = {
13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], 13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
14 MESSAGES: { 14 MESSAGES: {
15 'required': this.i18n('Report reason is required.'), 15 'required': this.i18n('Report reason is required.'),
@@ -18,7 +18,7 @@ export class VideoAbuseValidatorsService {
18 } 18 }
19 } 19 }
20 20
21 this.VIDEO_ABUSE_MODERATION_COMMENT = { 21 this.ABUSE_MODERATION_COMMENT = {
22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], 22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
23 MESSAGES: { 23 MESSAGES: {
24 'required': this.i18n('Moderation comment is required.'), 24 'required': this.i18n('Moderation comment is required.'),
diff --git a/client/src/app/shared/shared-forms/form-validators/index.ts b/client/src/app/shared/shared-forms/form-validators/index.ts
index 8b71841a9..b06a326ff 100644
--- a/client/src/app/shared/shared-forms/form-validators/index.ts
+++ b/client/src/app/shared/shared-forms/form-validators/index.ts
@@ -1,3 +1,4 @@
1export * from './abuse-validators.service'
1export * from './batch-domains-validators.service' 2export * from './batch-domains-validators.service'
2export * from './custom-config-validators.service' 3export * from './custom-config-validators.service'
3export * from './form-validator.service' 4export * from './form-validator.service'
@@ -6,7 +7,6 @@ export * from './instance-validators.service'
6export * from './login-validators.service' 7export * from './login-validators.service'
7export * from './reset-password-validators.service' 8export * from './reset-password-validators.service'
8export * from './user-validators.service' 9export * from './user-validators.service'
9export * from './video-abuse-validators.service'
10export * from './video-accept-ownership-validators.service' 10export * from './video-accept-ownership-validators.service'
11export * from './video-block-validators.service' 11export * from './video-block-validators.service'
12export * from './video-captions-validators.service' 12export * from './video-captions-validators.service'
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts
index e82fa97d4..ba33704cf 100644
--- a/client/src/app/shared/shared-forms/shared-form.module.ts
+++ b/client/src/app/shared/shared-forms/shared-form.module.ts
@@ -11,7 +11,7 @@ import {
11 LoginValidatorsService, 11 LoginValidatorsService,
12 ResetPasswordValidatorsService, 12 ResetPasswordValidatorsService,
13 UserValidatorsService, 13 UserValidatorsService,
14 VideoAbuseValidatorsService, 14 AbuseValidatorsService,
15 VideoAcceptOwnershipValidatorsService, 15 VideoAcceptOwnershipValidatorsService,
16 VideoBlockValidatorsService, 16 VideoBlockValidatorsService,
17 VideoCaptionsValidatorsService, 17 VideoCaptionsValidatorsService,
@@ -69,7 +69,7 @@ import { TimestampInputComponent } from './timestamp-input.component'
69 LoginValidatorsService, 69 LoginValidatorsService,
70 ResetPasswordValidatorsService, 70 ResetPasswordValidatorsService,
71 UserValidatorsService, 71 UserValidatorsService,
72 VideoAbuseValidatorsService, 72 AbuseValidatorsService,
73 VideoAcceptOwnershipValidatorsService, 73 VideoAcceptOwnershipValidatorsService,
74 VideoBlockValidatorsService, 74 VideoBlockValidatorsService,
75 VideoCaptionsValidatorsService, 75 VideoCaptionsValidatorsService,
diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts
index 5fc7989dd..bda88bdee 100644
--- a/client/src/app/shared/shared-main/account/actor.model.ts
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -14,7 +14,9 @@ export abstract class Actor implements ActorServer {
14 14
15 avatarUrl: string 15 avatarUrl: string
16 16
17 static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) { 17 isLocal: boolean
18
19 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
18 if (actor?.avatar?.url) return actor.avatar.url 20 if (actor?.avatar?.url) return actor.avatar.url
19 21
20 if (actor && actor.avatar) { 22 if (actor && actor.avatar) {
@@ -46,10 +48,16 @@ export abstract class Actor implements ActorServer {
46 this.host = hash.host 48 this.host = hash.host
47 this.followingCount = hash.followingCount 49 this.followingCount = hash.followingCount
48 this.followersCount = hash.followersCount 50 this.followersCount = hash.followersCount
49 this.createdAt = new Date(hash.createdAt.toString()) 51
50 this.updatedAt = new Date(hash.updatedAt.toString()) 52 if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
53 if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
54
51 this.avatar = hash.avatar 55 this.avatar = hash.avatar
52 56
57 const absoluteAPIUrl = getAbsoluteAPIUrl()
58 const thisHost = new URL(absoluteAPIUrl).host
59 this.isLocal = this.host.trim() === thisHost
60
53 this.updateComputedAttributes() 61 this.updateComputedAttributes()
54 } 62 }
55 63
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index de25d3ab9..61b48a806 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -25,9 +25,22 @@ export class UserNotification implements UserNotificationServer {
25 video: VideoInfo 25 video: VideoInfo
26 } 26 }
27 27
28 videoAbuse?: { 28 abuse?: {
29 id: number 29 id: number
30 video: VideoInfo 30
31 video?: VideoInfo
32
33 comment?: {
34 threadId: number
35
36 video: {
37 id: number
38 uuid: string
39 name: string
40 }
41 }
42
43 account?: ActorInfo
31 } 44 }
32 45
33 videoBlacklist?: { 46 videoBlacklist?: {
@@ -55,7 +68,7 @@ export class UserNotification implements UserNotificationServer {
55 // Additional fields 68 // Additional fields
56 videoUrl?: string 69 videoUrl?: string
57 commentUrl?: any[] 70 commentUrl?: any[]
58 videoAbuseUrl?: string 71 abuseUrl?: string
59 videoAutoBlacklistUrl?: string 72 videoAutoBlacklistUrl?: string
60 accountUrl?: string 73 accountUrl?: string
61 videoImportIdentifier?: string 74 videoImportIdentifier?: string
@@ -78,7 +91,7 @@ export class UserNotification implements UserNotificationServer {
78 this.comment = hash.comment 91 this.comment = hash.comment
79 if (this.comment) this.setAvatarUrl(this.comment.account) 92 if (this.comment) this.setAvatarUrl(this.comment.account)
80 93
81 this.videoAbuse = hash.videoAbuse 94 this.abuse = hash.abuse
82 95
83 this.videoBlacklist = hash.videoBlacklist 96 this.videoBlacklist = hash.videoBlacklist
84 97
@@ -104,12 +117,15 @@ export class UserNotification implements UserNotificationServer {
104 case UserNotificationType.COMMENT_MENTION: 117 case UserNotificationType.COMMENT_MENTION:
105 if (!this.comment) break 118 if (!this.comment) break
106 this.accountUrl = this.buildAccountUrl(this.comment.account) 119 this.accountUrl = this.buildAccountUrl(this.comment.account)
107 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ] 120 this.commentUrl = this.buildCommentUrl(this.comment)
108 break 121 break
109 122
110 case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: 123 case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
111 this.videoAbuseUrl = '/admin/moderation/video-abuses/list' 124 this.abuseUrl = '/admin/moderation/abuses/list'
112 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) 125
126 if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video)
127 else if (this.abuse.comment) this.commentUrl = this.buildCommentUrl(this.abuse.comment)
128 else if (this.abuse.account) this.accountUrl = this.buildAccountUrl(this.abuse.account)
113 break 129 break
114 130
115 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: 131 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
@@ -178,7 +194,11 @@ export class UserNotification implements UserNotificationServer {
178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName 194 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
179 } 195 }
180 196
181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { 197 private buildCommentUrl (comment: { video: { uuid: string }, threadId: number }) {
198 return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
199 }
200
201 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) 202 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
183 } 203 }
184} 204}
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
index d5be1470e..8127ae979 100644
--- a/client/src/app/shared/shared-main/users/user-notifications.component.html
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -19,7 +19,7 @@
19 19
20 <ng-template #noVideo> 20 <ng-template #noVideo>
21 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 21 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
22 22
23 <div class="message" i18n> 23 <div class="message" i18n>
24 The notification concerns a video now unavailable 24 The notification concerns a video now unavailable
25 </div> 25 </div>
@@ -42,11 +42,24 @@
42 </div> 42 </div>
43 </ng-container> 43 </ng-container>
44 44
45 <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS"> 45 <ng-container *ngSwitchCase="UserNotificationType.NEW_ABUSE_FOR_MODERATORS">
46 <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon> 46 <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
47 47
48 <div class="message" i18n> 48 <div class="message" *ngIf="notification.videoUrl" i18n>
49 <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a> 49 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.abuse.video.name }}</a>
50 </div>
51
52 <div class="message" *ngIf="notification.commentUrl" i18n>
53 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new comment abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.abuse.comment.video.name }}</a>
54 </div>
55
56 <div class="message" *ngIf="notification.accountUrl" i18n>
57 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new account abuse</a> has been created on account <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.abuse.account.displayName }}</a>
58 </div>
59
60 <!-- Deleted entity associated to the abuse -->
61 <div class="message" *ngIf="!notification.videoUrl && !notification.commentUrl && !notification.accountUrl" i18n>
62 <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new abuse</a> has been created
50 </div> 63 </div>
51 </ng-container> 64 </ng-container>
52 65
@@ -65,7 +78,7 @@
65 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl"> 78 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
66 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> 79 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
67 </a> 80 </a>
68 81
69 <div class="message" i18n> 82 <div class="message" i18n>
70 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a> 83 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
71 </div> 84 </div>
@@ -73,7 +86,7 @@
73 86
74 <ng-template #noComment> 87 <ng-template #noComment>
75 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 88 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
76 89
77 <div class="message" i18n> 90 <div class="message" i18n>
78 The notification concerns a comment now unavailable 91 The notification concerns a comment now unavailable
79 </div> 92 </div>
diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts
new file mode 100644
index 000000000..95ac16955
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/abuse.service.ts
@@ -0,0 +1,154 @@
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 { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
9import { environment } from '../../../environments/environment'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11
12@Injectable()
13export class AbuseService {
14 private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
15
16 constructor (
17 private i18n: I18n,
18 private authHttp: HttpClient,
19 private restService: RestService,
20 private restExtractor: RestExtractor
21 ) { }
22
23 getAbuses (options: {
24 pagination: RestPagination,
25 sort: SortMeta,
26 search?: string
27 }): Observable<ResultList<Abuse>> {
28 const { pagination, sort, search } = options
29 const url = AbuseService.BASE_ABUSE_URL
30
31 let params = new HttpParams()
32 params = this.restService.addRestGetParams(params, pagination, sort)
33
34 if (search) {
35 const filters = this.restService.parseQueryStringFilter(search, {
36 id: { prefix: '#' },
37 state: {
38 prefix: 'state:',
39 handler: v => {
40 if (v === 'accepted') return AbuseState.ACCEPTED
41 if (v === 'pending') return AbuseState.PENDING
42 if (v === 'rejected') return AbuseState.REJECTED
43
44 return undefined
45 }
46 },
47 videoIs: {
48 prefix: 'videoIs:',
49 handler: v => {
50 if (v === 'deleted') return v
51 if (v === 'blacklisted') return v
52
53 return undefined
54 }
55 },
56 searchReporter: { prefix: 'reporter:' },
57 searchReportee: { prefix: 'reportee:' },
58 predefinedReason: { prefix: 'tag:' }
59 })
60
61 params = this.restService.addObjectParams(params, filters)
62 }
63
64 return this.authHttp.get<ResultList<Abuse>>(url, { params })
65 .pipe(
66 catchError(res => this.restExtractor.handleError(res))
67 )
68 }
69
70 reportVideo (parameters: AbuseCreate) {
71 const url = AbuseService.BASE_ABUSE_URL
72
73 const body = omit(parameters, ['id'])
74
75 return this.authHttp.post(url, body)
76 .pipe(
77 map(this.restExtractor.extractDataBool),
78 catchError(res => this.restExtractor.handleError(res))
79 )
80 }
81
82 updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
83 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
84
85 return this.authHttp.put(url, abuseUpdate)
86 .pipe(
87 map(this.restExtractor.extractDataBool),
88 catchError(res => this.restExtractor.handleError(res))
89 )
90 }
91
92 removeAbuse (abuse: Abuse) {
93 const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
94
95 return this.authHttp.delete(url)
96 .pipe(
97 map(this.restExtractor.extractDataBool),
98 catchError(res => this.restExtractor.handleError(res))
99 )
100 }
101
102 getPrefefinedReasons (type: AbuseFilter) {
103 let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
104 {
105 id: 'violentOrRepulsive',
106 label: this.i18n('Violent or repulsive'),
107 help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
108 },
109 {
110 id: 'hatefulOrAbusive',
111 label: this.i18n('Hateful or abusive'),
112 help: this.i18n('Contains abusive, racist or sexist language or iconography.')
113 },
114 {
115 id: 'spamOrMisleading',
116 label: this.i18n('Spam, ad or false news'),
117 help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
118 },
119 {
120 id: 'privacy',
121 label: this.i18n('Privacy breach or doxxing'),
122 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).')
123 },
124 {
125 id: 'rights',
126 label: this.i18n('Intellectual property violation'),
127 help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
128 },
129 {
130 id: 'serverRules',
131 label: this.i18n('Breaks server rules'),
132 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.')
133 }
134 ]
135
136 if (type === 'video') {
137 reasons = reasons.concat([
138 {
139 id: 'thumbnails',
140 label: this.i18n('Thumbnails'),
141 help: this.i18n('The above can only be seen in thumbnails.')
142 },
143 {
144 id: 'captions',
145 label: this.i18n('Captions'),
146 help: this.i18n('The above can only be seen in captions (please describe which).')
147 }
148 ])
149 }
150
151 return reasons
152 }
153
154}
diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts
index 8e74254f6..41c910ffe 100644
--- a/client/src/app/shared/shared-moderation/index.ts
+++ b/client/src/app/shared/shared-moderation/index.ts
@@ -1,3 +1,6 @@
1export * from './report-modals'
2
3export * from './abuse.service'
1export * from './account-block.model' 4export * from './account-block.model'
2export * from './account-blocklist.component' 5export * from './account-blocklist.component'
3export * from './batch-domains-modal.component' 6export * from './batch-domains-modal.component'
@@ -6,8 +9,6 @@ export * from './bulk.service'
6export * from './server-blocklist.component' 9export * from './server-blocklist.component'
7export * from './user-ban-modal.component' 10export * from './user-ban-modal.component'
8export * from './user-moderation-dropdown.component' 11export * from './user-moderation-dropdown.component'
9export * from './video-abuse.service'
10export * from './video-block.component' 12export * from './video-block.component'
11export * from './video-block.service' 13export * from './video-block.service'
12export * from './video-report.component'
13export * from './shared-moderation.module' 14export * from './shared-moderation.module'
diff --git a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
new file mode 100644
index 000000000..78ca934c7
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
@@ -0,0 +1,94 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core'
4import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Account } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
10import { AbuseService } from '../abuse.service'
11
12@Component({
13 selector: 'my-account-report',
14 templateUrl: './report.component.html',
15 styleUrls: [ './report.component.scss' ]
16})
17export class AccountReportComponent extends FormReactive implements OnInit {
18 @Input() account: Account = null
19
20 @ViewChild('modal', { static: true }) modal: NgbModal
21
22 error: string = null
23 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
24 modalTitle: string
25
26 private openedModal: NgbModalRef
27
28 constructor (
29 protected formValidatorService: FormValidatorService,
30 private modalService: NgbModal,
31 private abuseValidatorsService: AbuseValidatorsService,
32 private abuseService: AbuseService,
33 private notifier: Notifier,
34 private i18n: I18n
35 ) {
36 super()
37 }
38
39 get currentHost () {
40 return window.location.host
41 }
42
43 get originHost () {
44 if (this.isRemote()) {
45 return this.account.host
46 }
47
48 return ''
49 }
50
51 ngOnInit () {
52 this.modalTitle = this.i18n('Report {{displayName}}', { displayName: this.account.displayName })
53
54 this.buildForm({
55 reason: this.abuseValidatorsService.ABUSE_REASON,
56 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
57 })
58
59 this.predefinedReasons = this.abuseService.getPrefefinedReasons('account')
60 }
61
62 show () {
63 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
64 }
65
66 hide () {
67 this.openedModal.close()
68 this.openedModal = null
69 }
70
71 report () {
72 const reason = this.form.get('reason').value
73 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
74
75 this.abuseService.reportVideo({
76 reason,
77 predefinedReasons,
78 account: {
79 id: this.account.id
80 }
81 }).subscribe(
82 () => {
83 this.notifier.success(this.i18n('Account reported.'))
84 this.hide()
85 },
86
87 err => this.notifier.error(err.message)
88 )
89 }
90
91 isRemote () {
92 return !this.account.isLocal
93 }
94}
diff --git a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
new file mode 100644
index 000000000..00d7b8d34
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
@@ -0,0 +1,94 @@
1import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core'
4import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { VideoComment } from '@app/shared/shared-video-comment'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
10import { AbuseService } from '../abuse.service'
11
12@Component({
13 selector: 'my-comment-report',
14 templateUrl: './report.component.html',
15 styleUrls: [ './report.component.scss' ]
16})
17export class CommentReportComponent extends FormReactive implements OnInit {
18 @Input() comment: VideoComment = null
19
20 @ViewChild('modal', { static: true }) modal: NgbModal
21
22 modalTitle: string
23 error: string = null
24 predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
25
26 private openedModal: NgbModalRef
27
28 constructor (
29 protected formValidatorService: FormValidatorService,
30 private modalService: NgbModal,
31 private abuseValidatorsService: AbuseValidatorsService,
32 private abuseService: AbuseService,
33 private notifier: Notifier,
34 private i18n: I18n
35 ) {
36 super()
37 }
38
39 get currentHost () {
40 return window.location.host
41 }
42
43 get originHost () {
44 if (this.isRemote()) {
45 return this.comment.account.host
46 }
47
48 return ''
49 }
50
51 ngOnInit () {
52 this.modalTitle = this.i18n('Report comment')
53
54 this.buildForm({
55 reason: this.abuseValidatorsService.ABUSE_REASON,
56 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
57 })
58
59 this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment')
60 }
61
62 show () {
63 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
64 }
65
66 hide () {
67 this.openedModal.close()
68 this.openedModal = null
69 }
70
71 report () {
72 const reason = this.form.get('reason').value
73 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
74
75 this.abuseService.reportVideo({
76 reason,
77 predefinedReasons,
78 comment: {
79 id: this.comment.id
80 }
81 }).subscribe(
82 () => {
83 this.notifier.success(this.i18n('Comment reported.'))
84 this.hide()
85 },
86
87 err => this.notifier.error(err.message)
88 )
89 }
90
91 isRemote () {
92 return !this.comment.isLocal
93 }
94}
diff --git a/client/src/app/shared/shared-moderation/report-modals/index.ts b/client/src/app/shared/shared-moderation/report-modals/index.ts
new file mode 100644
index 000000000..f3c4058ae
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/index.ts
@@ -0,0 +1,3 @@
1export * from './account-report.component'
2export * from './comment-report.component'
3export * from './video-report.component'
diff --git a/client/src/app/shared/shared-moderation/report-modals/report.component.html b/client/src/app/shared/shared-moderation/report-modals/report.component.html
new file mode 100644
index 000000000..bda62312f
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/report.component.html
@@ -0,0 +1,62 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 class="modal-title">{{ modalTitle }}</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
18 <div class="form-group" *ngFor="let reason of predefinedReasons">
19 <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
20 <ng-template *ngIf="reason.help" ptTemplate="help">
21 <div [innerHTML]="reason.help"></div>
22 </ng-template>
23
24 <ng-container *ngIf="reason.description" ngProjectAs="description">
25 <div [innerHTML]="reason.description"></div>
26 </ng-container>
27 </my-peertube-checkbox>
28 </div>
29
30 </ng-container>
31 </div>
32
33 </div>
34
35 <div class="col-7">
36 <div i18n class="information">
37 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemote()"> and will be forwarded to the comment origin ({{ originHost }}) too</ng-container>.
38 </div>
39
40 <div class="form-group">
41 <textarea
42 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
43 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
44 ></textarea>
45 <div *ngIf="formErrors.reason" class="form-error">
46 {{ formErrors.reason }}
47 </div>
48 </div>
49 </div>
50 </div>
51
52 <div class="form-group inputs">
53 <input
54 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
55 (click)="hide()" (key.enter)="hide()"
56 >
57 <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
58 </div>
59
60 </form>
61 </div>
62</ng-template>
diff --git a/client/src/app/shared/shared-moderation/video-report.component.scss b/client/src/app/shared/shared-moderation/report-modals/report.component.scss
index b2606cbd8..b2606cbd8 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.scss
+++ b/client/src/app/shared/shared-moderation/report-modals/report.component.scss
diff --git a/client/src/app/shared/shared-moderation/video-report.component.html b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html
index d6beb6d2a..4947088d1 100644
--- a/client/src/app/shared/shared-moderation/video-report.component.html
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.html
@@ -14,16 +14,19 @@
14 14
15 <div class="ml-2 mt-2 d-flex flex-column"> 15 <div class="ml-2 mt-2 d-flex flex-column">
16 <ng-container formGroupName="predefinedReasons"> 16 <ng-container formGroupName="predefinedReasons">
17
17 <div class="form-group" *ngFor="let reason of predefinedReasons"> 18 <div class="form-group" *ngFor="let reason of predefinedReasons">
18 <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> 19 <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
19 <ng-template *ngIf="reason.help" ptTemplate="help"> 20 <ng-template *ngIf="reason.help" ptTemplate="help">
20 <div [innerHTML]="reason.help"></div> 21 <div [innerHTML]="reason.help"></div>
21 </ng-template> 22 </ng-template>
23
22 <ng-container *ngIf="reason.description" ngProjectAs="description"> 24 <ng-container *ngIf="reason.description" ngProjectAs="description">
23 <div [innerHTML]="reason.description"></div> 25 <div [innerHTML]="reason.description"></div>
24 </ng-container> 26 </ng-container>
25 </my-peertube-checkbox> 27 </my-peertube-checkbox>
26 </div> 28 </div>
29
27 </ng-container> 30 </ng-container>
28 </div> 31 </div>
29 32
@@ -69,11 +72,11 @@
69 </div> 72 </div>
70 73
71 <div i18n class="information"> 74 <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>. 75 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemote()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
73 </div> 76 </div>
74 77
75 <div class="form-group"> 78 <div class="form-group">
76 <textarea 79 <textarea
77 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus 80 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
78 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" 81 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
79 ></textarea> 82 ></textarea>
diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
new file mode 100644
index 000000000..7d53ea3c9
--- /dev/null
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
@@ -0,0 +1,122 @@
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 { AbuseValidatorsService, FormReactive, FormValidatorService } 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 { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
11import { Video } from '../../shared-main'
12import { AbuseService } from '../abuse.service'
13
14@Component({
15 selector: 'my-video-report',
16 templateUrl: './video-report.component.html',
17 styleUrls: [ './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: AbusePredefinedReasonsString, 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 abuseValidatorsService: AbuseValidatorsService,
34 private abuseService: AbuseService,
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.isRemote()) {
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.abuseValidatorsService.ABUSE_REASON,
73 predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null),
74 timestamp: {
75 hasStart: null,
76 startAt: null,
77 hasEnd: null,
78 endAt: null
79 }
80 })
81
82 this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
83
84 this.embedHtml = this.getVideoEmbed()
85 }
86
87 show () {
88 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
89 }
90
91 hide () {
92 this.openedModal.close()
93 this.openedModal = null
94 }
95
96 report () {
97 const reason = this.form.get('reason').value
98 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
99 const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
100
101 this.abuseService.reportVideo({
102 reason,
103 predefinedReasons,
104 video: {
105 id: this.video.id,
106 startAt: hasStart && startAt ? startAt : undefined,
107 endAt: hasEnd && endAt ? endAt : undefined
108 }
109 }).subscribe(
110 () => {
111 this.notifier.success(this.i18n('Video reported.'))
112 this.hide()
113 },
114
115 err => this.notifier.error(err.message)
116 )
117 }
118
119 isRemote () {
120 return !this.video.isLocal
121 }
122}
diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
index f7e64dfa3..8fa9ee794 100644
--- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts
+++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts
@@ -3,21 +3,23 @@ import { NgModule } from '@angular/core'
3import { SharedFormModule } from '../shared-forms/shared-form.module' 3import { SharedFormModule } from '../shared-forms/shared-form.module'
4import { SharedGlobalIconModule } from '../shared-icons' 4import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module' 5import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedVideoCommentModule } from '../shared-video-comment'
7import { AbuseService } from './abuse.service'
6import { BatchDomainsModalComponent } from './batch-domains-modal.component' 8import { BatchDomainsModalComponent } from './batch-domains-modal.component'
7import { BlocklistService } from './blocklist.service' 9import { BlocklistService } from './blocklist.service'
8import { BulkService } from './bulk.service' 10import { BulkService } from './bulk.service'
9import { UserBanModalComponent } from './user-ban-modal.component' 11import { UserBanModalComponent } from './user-ban-modal.component'
10import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' 12import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
11import { VideoAbuseService } from './video-abuse.service'
12import { VideoBlockComponent } from './video-block.component' 13import { VideoBlockComponent } from './video-block.component'
13import { VideoBlockService } from './video-block.service' 14import { VideoBlockService } from './video-block.service'
14import { VideoReportComponent } from './video-report.component' 15import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals'
15 16
16@NgModule({ 17@NgModule({
17 imports: [ 18 imports: [
18 SharedMainModule, 19 SharedMainModule,
19 SharedFormModule, 20 SharedFormModule,
20 SharedGlobalIconModule 21 SharedGlobalIconModule,
22 SharedVideoCommentModule
21 ], 23 ],
22 24
23 declarations: [ 25 declarations: [
@@ -25,7 +27,9 @@ import { VideoReportComponent } from './video-report.component'
25 UserModerationDropdownComponent, 27 UserModerationDropdownComponent,
26 VideoBlockComponent, 28 VideoBlockComponent,
27 VideoReportComponent, 29 VideoReportComponent,
28 BatchDomainsModalComponent 30 BatchDomainsModalComponent,
31 CommentReportComponent,
32 AccountReportComponent
29 ], 33 ],
30 34
31 exports: [ 35 exports: [
@@ -33,13 +37,15 @@ import { VideoReportComponent } from './video-report.component'
33 UserModerationDropdownComponent, 37 UserModerationDropdownComponent,
34 VideoBlockComponent, 38 VideoBlockComponent,
35 VideoReportComponent, 39 VideoReportComponent,
36 BatchDomainsModalComponent 40 BatchDomainsModalComponent,
41 CommentReportComponent,
42 AccountReportComponent
37 ], 43 ],
38 44
39 providers: [ 45 providers: [
40 BlocklistService, 46 BlocklistService,
41 BulkService, 47 BulkService,
42 VideoAbuseService, 48 AbuseService,
43 VideoBlockService 49 VideoBlockService
44 ] 50 ]
45}) 51})
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
index d3c37f082..78c2658df 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -16,6 +16,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
16 16
17 @Input() user: User 17 @Input() user: User
18 @Input() account: Account 18 @Input() account: Account
19 @Input() prependActions: DropdownAction<{ user: User, account: Account }>[]
19 20
20 @Input() buttonSize: 'normal' | 'small' = 'normal' 21 @Input() buttonSize: 'normal' | 'small' = 'normal'
21 @Input() placement = 'left-top left-bottom auto' 22 @Input() placement = 'left-top left-bottom auto'
@@ -250,6 +251,12 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
250 private buildActions () { 251 private buildActions () {
251 this.userActions = [] 252 this.userActions = []
252 253
254 if (this.prependActions) {
255 this.userActions = [
256 this.prependActions
257 ]
258 }
259
253 if (this.authService.isLoggedIn()) { 260 if (this.authService.isLoggedIn()) {
254 const authUser = this.authService.getUser() 261 const authUser = this.authService.getUser()
255 262
diff --git a/client/src/app/shared/shared-moderation/video-abuse.service.ts b/client/src/app/shared/shared-moderation/video-abuse.service.ts
deleted file mode 100644
index 44dea44a5..000000000
--- a/client/src/app/shared/shared-moderation/video-abuse.service.ts
+++ /dev/null
@@ -1,98 +0,0 @@
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-report.component.ts b/client/src/app/shared/shared-moderation/video-report.component.ts
deleted file mode 100644
index 11c805636..000000000
--- a/client/src/app/shared/shared-moderation/video-report.component.ts
+++ /dev/null
@@ -1,161 +0,0 @@
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}
diff --git a/client/src/app/shared/shared-video-comment/index.ts b/client/src/app/shared/shared-video-comment/index.ts
new file mode 100644
index 000000000..b1195f232
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/index.ts
@@ -0,0 +1,5 @@
1export * from './video-comment.service'
2export * from './video-comment.model'
3export * from './video-comment-thread-tree.model'
4
5export * from './shared-video-comment.module'
diff --git a/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts
new file mode 100644
index 000000000..41b329861
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/shared-video-comment.module.ts
@@ -0,0 +1,19 @@
1
2import { NgModule } from '@angular/core'
3import { SharedMainModule } from '../shared-main/shared-main.module'
4import { VideoCommentService } from './video-comment.service'
5
6@NgModule({
7 imports: [
8 SharedMainModule
9 ],
10
11 declarations: [ ],
12
13 exports: [ ],
14
15 providers: [
16 VideoCommentService
17 ]
18})
19export class SharedVideoCommentModule { }
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts
index 7c2aaeadd..7c2aaeadd 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/shared/shared-video-comment/video-comment.model.ts
index e85443196..e85443196 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.model.ts
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index a73fb9ca8..81c65aa38 100644
--- a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -11,7 +11,7 @@ import {
11 VideoCommentCreate, 11 VideoCommentCreate,
12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel 12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
13} from '@shared/models' 13} from '@shared/models'
14import { environment } from '../../../../environments/environment' 14import { environment } from '../../../environments/environment'
15import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 15import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
16import { VideoComment } from './video-comment.model' 16import { VideoComment } from './video-comment.model'
17 17
diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts
new file mode 100644
index 000000000..04a0c06e3
--- /dev/null
+++ b/server/controllers/api/abuse.ts
@@ -0,0 +1,168 @@
1import * as express from 'express'
2import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
3import { AbuseModel } from '@server/models/abuse/abuse'
4import { getServerActor } from '@server/models/application/application'
5import { AbuseCreate, abusePredefinedReasonsMap, AbuseState, UserRight } from '../../../shared'
6import { getFormattedObjects } from '../../helpers/utils'
7import { sequelizeTypescript } from '../../initializers/database'
8import {
9 abuseGetValidator,
10 abuseListValidator,
11 abuseReportValidator,
12 abusesSortValidator,
13 abuseUpdateValidator,
14 asyncMiddleware,
15 asyncRetryTransactionMiddleware,
16 authenticate,
17 ensureUserHasRight,
18 paginationValidator,
19 setDefaultPagination,
20 setDefaultSort
21} from '../../middlewares'
22import { AccountModel } from '../../models/account/account'
23
24const abuseRouter = express.Router()
25
26abuseRouter.get('/',
27 authenticate,
28 ensureUserHasRight(UserRight.MANAGE_ABUSES),
29 paginationValidator,
30 abusesSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 abuseListValidator,
34 asyncMiddleware(listAbuses)
35)
36abuseRouter.put('/:id',
37 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_ABUSES),
39 asyncMiddleware(abuseUpdateValidator),
40 asyncRetryTransactionMiddleware(updateAbuse)
41)
42abuseRouter.post('/',
43 authenticate,
44 asyncMiddleware(abuseReportValidator),
45 asyncRetryTransactionMiddleware(reportAbuse)
46)
47abuseRouter.delete('/:id',
48 authenticate,
49 ensureUserHasRight(UserRight.MANAGE_ABUSES),
50 asyncMiddleware(abuseGetValidator),
51 asyncRetryTransactionMiddleware(deleteAbuse)
52)
53
54// ---------------------------------------------------------------------------
55
56export {
57 abuseRouter,
58
59 // FIXME: deprecated in 2.3. Remove these exports
60 listAbuses,
61 updateAbuse,
62 deleteAbuse,
63 reportAbuse
64}
65
66// ---------------------------------------------------------------------------
67
68async function listAbuses (req: express.Request, res: express.Response) {
69 const user = res.locals.oauth.token.user
70 const serverActor = await getServerActor()
71
72 const resultList = await AbuseModel.listForApi({
73 start: req.query.start,
74 count: req.query.count,
75 sort: req.query.sort,
76 id: req.query.id,
77 filter: req.query.filter,
78 predefinedReason: req.query.predefinedReason,
79 search: req.query.search,
80 state: req.query.state,
81 videoIs: req.query.videoIs,
82 searchReporter: req.query.searchReporter,
83 searchReportee: req.query.searchReportee,
84 searchVideo: req.query.searchVideo,
85 searchVideoChannel: req.query.searchVideoChannel,
86 serverAccountId: serverActor.Account.id,
87 user
88 })
89
90 return res.json(getFormattedObjects(resultList.data, resultList.total))
91}
92
93async function updateAbuse (req: express.Request, res: express.Response) {
94 const abuse = res.locals.abuse
95
96 if (req.body.moderationComment !== undefined) abuse.moderationComment = req.body.moderationComment
97 if (req.body.state !== undefined) abuse.state = req.body.state
98
99 await sequelizeTypescript.transaction(t => {
100 return abuse.save({ transaction: t })
101 })
102
103 // Do not send the delete to other instances, we updated OUR copy of this abuse
104
105 return res.type('json').status(204).end()
106}
107
108async function deleteAbuse (req: express.Request, res: express.Response) {
109 const abuse = res.locals.abuse
110
111 await sequelizeTypescript.transaction(t => {
112 return abuse.destroy({ transaction: t })
113 })
114
115 // Do not send the delete to other instances, we delete OUR copy of this abuse
116
117 return res.type('json').status(204).end()
118}
119
120async function reportAbuse (req: express.Request, res: express.Response) {
121 const videoInstance = res.locals.videoAll
122 const commentInstance = res.locals.videoCommentFull
123 const accountInstance = res.locals.account
124
125 const body: AbuseCreate = req.body
126
127 const { id } = await sequelizeTypescript.transaction(async t => {
128 const reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
129 const predefinedReasons = body.predefinedReasons?.map(r => abusePredefinedReasonsMap[r])
130
131 const baseAbuse = {
132 reporterAccountId: reporterAccount.id,
133 reason: body.reason,
134 state: AbuseState.PENDING,
135 predefinedReasons
136 }
137
138 if (body.video) {
139 return createVideoAbuse({
140 baseAbuse,
141 videoInstance,
142 reporterAccount,
143 transaction: t,
144 startAt: body.video.startAt,
145 endAt: body.video.endAt
146 })
147 }
148
149 if (body.comment) {
150 return createVideoCommentAbuse({
151 baseAbuse,
152 commentInstance,
153 reporterAccount,
154 transaction: t
155 })
156 }
157
158 // Account report
159 return createAccountAbuse({
160 baseAbuse,
161 accountInstance,
162 reporterAccount,
163 transaction: t
164 })
165 })
166
167 return res.json({ abuse: { id } })
168}
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index c334a26b4..eda9e04d1 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -3,6 +3,7 @@ import * as express from 'express'
3import * as RateLimit from 'express-rate-limit' 3import * as RateLimit from 'express-rate-limit'
4import { badRequest } from '../../helpers/express-utils' 4import { badRequest } from '../../helpers/express-utils'
5import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
6import { abuseRouter } from './abuse'
6import { accountsRouter } from './accounts' 7import { accountsRouter } from './accounts'
7import { bulkRouter } from './bulk' 8import { bulkRouter } from './bulk'
8import { configRouter } from './config' 9import { configRouter } from './config'
@@ -32,6 +33,7 @@ const apiRateLimiter = RateLimit({
32apiRouter.use(apiRateLimiter) 33apiRouter.use(apiRateLimiter)
33 34
34apiRouter.use('/server', serverRouter) 35apiRouter.use('/server', serverRouter)
36apiRouter.use('/abuses', abuseRouter)
35apiRouter.use('/bulk', bulkRouter) 37apiRouter.use('/bulk', bulkRouter)
36apiRouter.use('/oauth-clients', oauthClientsRouter) 38apiRouter.use('/oauth-clients', oauthClientsRouter)
37apiRouter.use('/config', configRouter) 39apiRouter.use('/config', configRouter)
diff --git a/server/controllers/api/users/my-history.ts b/server/controllers/api/users/my-history.ts
index 77a15e5fc..dc915977f 100644
--- a/server/controllers/api/users/my-history.ts
+++ b/server/controllers/api/users/my-history.ts
@@ -50,7 +50,5 @@ async function removeUserHistory (req: express.Request, res: express.Response) {
50 return UserVideoHistoryModel.removeUserHistoryBefore(user, beforeDate, t) 50 return UserVideoHistoryModel.removeUserHistoryBefore(user, beforeDate, t)
51 }) 51 })
52 52
53 // Do not send the delete to other instances, we delete OUR copy of this video abuse
54
55 return res.type('json').status(204).end() 53 return res.type('json').status(204).end()
56} 54}
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
index 017f5219e..0be51c128 100644
--- a/server/controllers/api/users/my-notifications.ts
+++ b/server/controllers/api/users/my-notifications.ts
@@ -68,7 +68,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
68 const values: UserNotificationSetting = { 68 const values: UserNotificationSetting = {
69 newVideoFromSubscription: body.newVideoFromSubscription, 69 newVideoFromSubscription: body.newVideoFromSubscription,
70 newCommentOnMyVideo: body.newCommentOnMyVideo, 70 newCommentOnMyVideo: body.newCommentOnMyVideo,
71 videoAbuseAsModerator: body.videoAbuseAsModerator, 71 abuseAsModerator: body.abuseAsModerator,
72 videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator, 72 videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator,
73 blacklistOnMyVideo: body.blacklistOnMyVideo, 73 blacklistOnMyVideo: body.blacklistOnMyVideo,
74 myVideoPublished: body.myVideoPublished, 74 myVideoPublished: body.myVideoPublished,
diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts
index ab2074459..b92a66360 100644
--- a/server/controllers/api/videos/abuse.ts
+++ b/server/controllers/api/videos/abuse.ts
@@ -1,9 +1,10 @@
1import * as express from 'express' 1import * as express from 'express'
2import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared' 2import { AbuseModel } from '@server/models/abuse/abuse'
3import { logger } from '../../../helpers/logger' 3import { getServerActor } from '@server/models/application/application'
4import { AbuseCreate, UserRight, VideoAbuseCreate } from '../../../../shared'
4import { getFormattedObjects } from '../../../helpers/utils' 5import { getFormattedObjects } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers/database'
6import { 6import {
7 abusesSortValidator,
7 asyncMiddleware, 8 asyncMiddleware,
8 asyncRetryTransactionMiddleware, 9 asyncRetryTransactionMiddleware,
9 authenticate, 10 authenticate,
@@ -12,28 +13,21 @@ import {
12 setDefaultPagination, 13 setDefaultPagination,
13 setDefaultSort, 14 setDefaultSort,
14 videoAbuseGetValidator, 15 videoAbuseGetValidator,
16 videoAbuseListValidator,
15 videoAbuseReportValidator, 17 videoAbuseReportValidator,
16 videoAbusesSortValidator, 18 videoAbuseUpdateValidator
17 videoAbuseUpdateValidator,
18 videoAbuseListValidator
19} from '../../../middlewares' 19} from '../../../middlewares'
20import { AccountModel } from '../../../models/account/account' 20import { deleteAbuse, reportAbuse, updateAbuse } from '../abuse'
21import { VideoAbuseModel } from '../../../models/video/video-abuse' 21
22import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger' 22// FIXME: deprecated in 2.3. Remove this controller
23import { Notifier } from '../../../lib/notifier'
24import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag'
25import { MVideoAbuseAccountVideo } from '../../../types/models/video'
26import { getServerActor } from '@server/models/application/application'
27import { MAccountDefault } from '@server/types/models'
28 23
29const auditLogger = auditLoggerFactory('abuse')
30const abuseVideoRouter = express.Router() 24const abuseVideoRouter = express.Router()
31 25
32abuseVideoRouter.get('/abuse', 26abuseVideoRouter.get('/abuse',
33 authenticate, 27 authenticate,
34 ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), 28 ensureUserHasRight(UserRight.MANAGE_ABUSES),
35 paginationValidator, 29 paginationValidator,
36 videoAbusesSortValidator, 30 abusesSortValidator,
37 setDefaultSort, 31 setDefaultSort,
38 setDefaultPagination, 32 setDefaultPagination,
39 videoAbuseListValidator, 33 videoAbuseListValidator,
@@ -41,7 +35,7 @@ abuseVideoRouter.get('/abuse',
41) 35)
42abuseVideoRouter.put('/:videoId/abuse/:id', 36abuseVideoRouter.put('/:videoId/abuse/:id',
43 authenticate, 37 authenticate,
44 ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), 38 ensureUserHasRight(UserRight.MANAGE_ABUSES),
45 asyncMiddleware(videoAbuseUpdateValidator), 39 asyncMiddleware(videoAbuseUpdateValidator),
46 asyncRetryTransactionMiddleware(updateVideoAbuse) 40 asyncRetryTransactionMiddleware(updateVideoAbuse)
47) 41)
@@ -52,7 +46,7 @@ abuseVideoRouter.post('/:videoId/abuse',
52) 46)
53abuseVideoRouter.delete('/:videoId/abuse/:id', 47abuseVideoRouter.delete('/:videoId/abuse/:id',
54 authenticate, 48 authenticate,
55 ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), 49 ensureUserHasRight(UserRight.MANAGE_ABUSES),
56 asyncMiddleware(videoAbuseGetValidator), 50 asyncMiddleware(videoAbuseGetValidator),
57 asyncRetryTransactionMiddleware(deleteVideoAbuse) 51 asyncRetryTransactionMiddleware(deleteVideoAbuse)
58) 52)
@@ -69,11 +63,12 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
69 const user = res.locals.oauth.token.user 63 const user = res.locals.oauth.token.user
70 const serverActor = await getServerActor() 64 const serverActor = await getServerActor()
71 65
72 const resultList = await VideoAbuseModel.listForApi({ 66 const resultList = await AbuseModel.listForApi({
73 start: req.query.start, 67 start: req.query.start,
74 count: req.query.count, 68 count: req.query.count,
75 sort: req.query.sort, 69 sort: req.query.sort,
76 id: req.query.id, 70 id: req.query.id,
71 filter: 'video',
77 predefinedReason: req.query.predefinedReason, 72 predefinedReason: req.query.predefinedReason,
78 search: req.query.search, 73 search: req.query.search,
79 state: req.query.state, 74 state: req.query.state,
@@ -90,74 +85,28 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
90} 85}
91 86
92async function updateVideoAbuse (req: express.Request, res: express.Response) { 87async function updateVideoAbuse (req: express.Request, res: express.Response) {
93 const videoAbuse = res.locals.videoAbuse 88 return updateAbuse(req, res)
94
95 if (req.body.moderationComment !== undefined) videoAbuse.moderationComment = req.body.moderationComment
96 if (req.body.state !== undefined) videoAbuse.state = req.body.state
97
98 await sequelizeTypescript.transaction(t => {
99 return videoAbuse.save({ transaction: t })
100 })
101
102 // Do not send the delete to other instances, we updated OUR copy of this video abuse
103
104 return res.type('json').status(204).end()
105} 89}
106 90
107async function deleteVideoAbuse (req: express.Request, res: express.Response) { 91async function deleteVideoAbuse (req: express.Request, res: express.Response) {
108 const videoAbuse = res.locals.videoAbuse 92 return deleteAbuse(req, res)
109
110 await sequelizeTypescript.transaction(t => {
111 return videoAbuse.destroy({ transaction: t })
112 })
113
114 // Do not send the delete to other instances, we delete OUR copy of this video abuse
115
116 return res.type('json').status(204).end()
117} 93}
118 94
119async function reportVideoAbuse (req: express.Request, res: express.Response) { 95async function reportVideoAbuse (req: express.Request, res: express.Response) {
120 const videoInstance = res.locals.videoAll 96 const oldBody = req.body as VideoAbuseCreate
121 const body: VideoAbuseCreate = req.body
122 let reporterAccount: MAccountDefault
123 let videoAbuseJSON: VideoAbuse
124
125 const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
126 reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
127 const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r])
128
129 const abuseToCreate = {
130 reporterAccountId: reporterAccount.id,
131 reason: body.reason,
132 videoId: videoInstance.id,
133 state: VideoAbuseState.PENDING,
134 predefinedReasons,
135 startAt: body.startAt,
136 endAt: body.endAt
137 }
138
139 const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t })
140 videoAbuseInstance.Video = videoInstance
141 videoAbuseInstance.Account = reporterAccount
142
143 // We send the video abuse to the origin server
144 if (videoInstance.isOwned() === false) {
145 await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance, t)
146 }
147 97
148 videoAbuseJSON = videoAbuseInstance.toFormattedJSON() 98 req.body = {
149 auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseJSON)) 99 accountId: res.locals.videoAll.VideoChannel.accountId,
150 100
151 return videoAbuseInstance 101 reason: oldBody.reason,
152 }) 102 predefinedReasons: oldBody.predefinedReasons,
153 103
154 Notifier.Instance.notifyOnNewVideoAbuse({ 104 video: {
155 videoAbuse: videoAbuseJSON, 105 id: res.locals.videoAll.id,
156 videoAbuseInstance, 106 startAt: oldBody.startAt,
157 reporter: reporterAccount.Actor.getIdentifier() 107 endAt: oldBody.endAt
158 }) 108 }
159 109 } as AbuseCreate
160 logger.info('Abuse report for video "%s" created.', videoInstance.name)
161 110
162 return res.json({ videoAbuse: videoAbuseJSON }).end() 111 return reportAbuse(req, res)
163} 112}
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts
index 0bbfbc753..954b0b69d 100644
--- a/server/helpers/audit-logger.ts
+++ b/server/helpers/audit-logger.ts
@@ -1,15 +1,15 @@
1import * as path from 'path'
2import * as express from 'express'
3import { diff } from 'deep-object-diff' 1import { diff } from 'deep-object-diff'
4import { chain } from 'lodash' 2import * as express from 'express'
5import * as flatten from 'flat' 3import * as flatten from 'flat'
4import { chain } from 'lodash'
5import * as path from 'path'
6import * as winston from 'winston' 6import * as winston from 'winston'
7import { jsonLoggerFormat, labelFormatter } from './logger' 7import { AUDIT_LOG_FILENAME } from '@server/initializers/constants'
8import { User, VideoAbuse, VideoChannel, VideoDetails, VideoImport } from '../../shared' 8import { Abuse, User, VideoChannel, VideoDetails, VideoImport } from '../../shared'
9import { VideoComment } from '../../shared/models/videos/video-comment.model'
10import { CustomConfig } from '../../shared/models/server/custom-config.model' 9import { CustomConfig } from '../../shared/models/server/custom-config.model'
10import { VideoComment } from '../../shared/models/videos/video-comment.model'
11import { CONFIG } from '../initializers/config' 11import { CONFIG } from '../initializers/config'
12import { AUDIT_LOG_FILENAME } from '@server/initializers/constants' 12import { jsonLoggerFormat, labelFormatter } from './logger'
13 13
14function getAuditIdFromRes (res: express.Response) { 14function getAuditIdFromRes (res: express.Response) {
15 return res.locals.oauth.token.User.username 15 return res.locals.oauth.token.User.username
@@ -212,18 +212,15 @@ class VideoChannelAuditView extends EntityAuditView {
212 } 212 }
213} 213}
214 214
215const videoAbuseKeysToKeep = [ 215const abuseKeysToKeep = [
216 'id', 216 'id',
217 'reason', 217 'reason',
218 'reporterAccount', 218 'reporterAccount',
219 'video-id',
220 'video-name',
221 'video-uuid',
222 'createdAt' 219 'createdAt'
223] 220]
224class VideoAbuseAuditView extends EntityAuditView { 221class AbuseAuditView extends EntityAuditView {
225 constructor (private readonly videoAbuse: VideoAbuse) { 222 constructor (private readonly abuse: Abuse) {
226 super(videoAbuseKeysToKeep, 'abuse', videoAbuse) 223 super(abuseKeysToKeep, 'abuse', abuse)
227 } 224 }
228} 225}
229 226
@@ -274,6 +271,6 @@ export {
274 CommentAuditView, 271 CommentAuditView,
275 UserAuditView, 272 UserAuditView,
276 VideoAuditView, 273 VideoAuditView,
277 VideoAbuseAuditView, 274 AbuseAuditView,
278 CustomConfigAuditView 275 CustomConfigAuditView
279} 276}
diff --git a/server/helpers/custom-validators/abuses.ts b/server/helpers/custom-validators/abuses.ts
new file mode 100644
index 000000000..0ca06a252
--- /dev/null
+++ b/server/helpers/custom-validators/abuses.ts
@@ -0,0 +1,61 @@
1import validator from 'validator'
2import { AbuseFilter, abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseVideoIs, AbuseCreate } from '@shared/models'
3import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
4import { exists, isArray } from './misc'
5
6const ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.ABUSES
7
8function isAbuseReasonValid (value: string) {
9 return exists(value) && validator.isLength(value, ABUSES_CONSTRAINTS_FIELDS.REASON)
10}
11
12function isAbusePredefinedReasonValid (value: AbusePredefinedReasonsString) {
13 return exists(value) && value in abusePredefinedReasonsMap
14}
15
16function isAbuseFilterValid (value: AbuseFilter) {
17 return value === 'video' || value === 'comment' || value === 'account'
18}
19
20function areAbusePredefinedReasonsValid (value: AbusePredefinedReasonsString[]) {
21 return exists(value) && isArray(value) && value.every(v => v in abusePredefinedReasonsMap)
22}
23
24function isAbuseTimestampValid (value: number) {
25 return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
26}
27
28function isAbuseTimestampCoherent (endAt: number, { req }) {
29 const startAt = (req.body as AbuseCreate).video.startAt
30
31 return exists(startAt) && endAt > startAt
32}
33
34function isAbuseModerationCommentValid (value: string) {
35 return exists(value) && validator.isLength(value, ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
36}
37
38function isAbuseStateValid (value: string) {
39 return exists(value) && ABUSE_STATES[value] !== undefined
40}
41
42function isAbuseVideoIsValid (value: AbuseVideoIs) {
43 return exists(value) && (
44 value === 'deleted' ||
45 value === 'blacklisted'
46 )
47}
48
49// ---------------------------------------------------------------------------
50
51export {
52 isAbuseReasonValid,
53 isAbuseFilterValid,
54 isAbusePredefinedReasonValid,
55 areAbusePredefinedReasonsValid as isAbusePredefinedReasonsValid,
56 isAbuseTimestampValid,
57 isAbuseTimestampCoherent,
58 isAbuseModerationCommentValid,
59 isAbuseStateValid,
60 isAbuseVideoIsValid
61}
diff --git a/server/helpers/custom-validators/activitypub/flag.ts b/server/helpers/custom-validators/activitypub/flag.ts
index 6452e297c..dc90b3667 100644
--- a/server/helpers/custom-validators/activitypub/flag.ts
+++ b/server/helpers/custom-validators/activitypub/flag.ts
@@ -1,9 +1,9 @@
1import { isActivityPubUrlValid } from './misc' 1import { isActivityPubUrlValid } from './misc'
2import { isVideoAbuseReasonValid } from '../video-abuses' 2import { isAbuseReasonValid } from '../abuses'
3 3
4function isFlagActivityValid (activity: any) { 4function isFlagActivityValid (activity: any) {
5 return activity.type === 'Flag' && 5 return activity.type === 'Flag' &&
6 isVideoAbuseReasonValid(activity.content) && 6 isAbuseReasonValid(activity.content) &&
7 isActivityPubUrlValid(activity.object) 7 isActivityPubUrlValid(activity.object)
8} 8}
9 9
diff --git a/server/helpers/custom-validators/video-abuses.ts b/server/helpers/custom-validators/video-abuses.ts
deleted file mode 100644
index 0c2c34268..000000000
--- a/server/helpers/custom-validators/video-abuses.ts
+++ /dev/null
@@ -1,56 +0,0 @@
1import validator from 'validator'
2
3import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
4import { exists, isArray } from './misc'
5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
6import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
7
8const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
9
10function isVideoAbuseReasonValid (value: string) {
11 return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON)
12}
13
14function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) {
15 return exists(value) && value in videoAbusePredefinedReasonsMap
16}
17
18function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) {
19 return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap)
20}
21
22function isVideoAbuseTimestampValid (value: number) {
23 return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
24}
25
26function isVideoAbuseTimestampCoherent (endAt: number, { req }) {
27 return exists(req.body.startAt) && endAt > req.body.startAt
28}
29
30function isVideoAbuseModerationCommentValid (value: string) {
31 return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
32}
33
34function isVideoAbuseStateValid (value: string) {
35 return exists(value) && VIDEO_ABUSE_STATES[value] !== undefined
36}
37
38function isAbuseVideoIsValid (value: VideoAbuseVideoIs) {
39 return exists(value) && (
40 value === 'deleted' ||
41 value === 'blacklisted'
42 )
43}
44
45// ---------------------------------------------------------------------------
46
47export {
48 isVideoAbuseReasonValid,
49 isVideoAbusePredefinedReasonValid,
50 isVideoAbusePredefinedReasonsValid,
51 isVideoAbuseTimestampValid,
52 isVideoAbuseTimestampCoherent,
53 isVideoAbuseModerationCommentValid,
54 isVideoAbuseStateValid,
55 isAbuseVideoIsValid
56}
diff --git a/server/helpers/custom-validators/video-comments.ts b/server/helpers/custom-validators/video-comments.ts
index 846f28b17..455ff4241 100644
--- a/server/helpers/custom-validators/video-comments.ts
+++ b/server/helpers/custom-validators/video-comments.ts
@@ -1,6 +1,8 @@
1import 'multer' 1import * as express from 'express'
2import validator from 'validator' 2import validator from 'validator'
3import { VideoCommentModel } from '@server/models/video/video-comment'
3import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { MVideoId } from '@server/types/models'
4 6
5const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS 7const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS
6 8
@@ -8,8 +10,83 @@ function isValidVideoCommentText (value: string) {
8 return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT) 10 return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT)
9} 11}
10 12
13async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
14 const id = parseInt(idArg + '', 10)
15 const videoComment = await VideoCommentModel.loadById(id)
16
17 if (!videoComment) {
18 res.status(404)
19 .json({ error: 'Video comment thread not found' })
20 .end()
21
22 return false
23 }
24
25 if (videoComment.videoId !== video.id) {
26 res.status(400)
27 .json({ error: 'Video comment is not associated to this video.' })
28 .end()
29
30 return false
31 }
32
33 if (videoComment.inReplyToCommentId !== null) {
34 res.status(400)
35 .json({ error: 'Video comment is not a thread.' })
36 .end()
37
38 return false
39 }
40
41 res.locals.videoCommentThread = videoComment
42 return true
43}
44
45async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
46 const id = parseInt(idArg + '', 10)
47 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
48
49 if (!videoComment) {
50 res.status(404)
51 .json({ error: 'Video comment thread not found' })
52 .end()
53
54 return false
55 }
56
57 if (videoComment.videoId !== video.id) {
58 res.status(400)
59 .json({ error: 'Video comment is not associated to this video.' })
60 .end()
61
62 return false
63 }
64
65 res.locals.videoCommentFull = videoComment
66 return true
67}
68
69async function doesCommentIdExist (idArg: number | string, res: express.Response) {
70 const id = parseInt(idArg + '', 10)
71 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
72
73 if (!videoComment) {
74 res.status(404)
75 .json({ error: 'Video comment thread not found' })
76
77 return false
78 }
79
80 res.locals.videoCommentFull = videoComment
81
82 return true
83}
84
11// --------------------------------------------------------------------------- 85// ---------------------------------------------------------------------------
12 86
13export { 87export {
14 isValidVideoCommentText 88 isValidVideoCommentText,
89 doesVideoCommentThreadExist,
90 doesVideoCommentExist,
91 doesCommentIdExist
15} 92}
diff --git a/server/helpers/middlewares/abuses.ts b/server/helpers/middlewares/abuses.ts
new file mode 100644
index 000000000..be8c8b449
--- /dev/null
+++ b/server/helpers/middlewares/abuses.ts
@@ -0,0 +1,47 @@
1import { Response } from 'express'
2import { AbuseModel } from '../../models/abuse/abuse'
3import { fetchVideo } from '../video'
4
5// FIXME: deprecated in 2.3. Remove this function
6async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
7 const abuseId = parseInt(abuseIdArg + '', 10)
8 let abuse = await AbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
9
10 if (!abuse) {
11 const userId = res.locals.oauth?.token.User.id
12 const video = await fetchVideo(videoUUID, 'all', userId)
13
14 if (video) abuse = await AbuseModel.loadByIdAndVideoId(abuseId, video.id)
15 }
16
17 if (abuse === null) {
18 res.status(404)
19 .json({ error: 'Video abuse not found' })
20
21 return false
22 }
23
24 res.locals.abuse = abuse
25 return true
26}
27
28async function doesAbuseExist (abuseId: number | string, res: Response) {
29 const abuse = await AbuseModel.loadById(parseInt(abuseId + '', 10))
30
31 if (!abuse) {
32 res.status(404)
33 .json({ error: 'Abuse not found' })
34
35 return false
36 }
37
38 res.locals.abuse = abuse
39 return true
40}
41
42// ---------------------------------------------------------------------------
43
44export {
45 doesAbuseExist,
46 doesVideoAbuseExist
47}
diff --git a/server/helpers/middlewares/accounts.ts b/server/helpers/middlewares/accounts.ts
index bddea7eaa..29b4ed1a6 100644
--- a/server/helpers/middlewares/accounts.ts
+++ b/server/helpers/middlewares/accounts.ts
@@ -3,8 +3,8 @@ import { AccountModel } from '../../models/account/account'
3import * as Bluebird from 'bluebird' 3import * as Bluebird from 'bluebird'
4import { MAccountDefault } from '../../types/models' 4import { MAccountDefault } from '../../types/models'
5 5
6function doesAccountIdExist (id: number, res: Response, sendNotFound = true) { 6function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
7 const promise = AccountModel.load(id) 7 const promise = AccountModel.load(parseInt(id + '', 10))
8 8
9 return doesAccountExist(promise, res, sendNotFound) 9 return doesAccountExist(promise, res, sendNotFound)
10} 10}
diff --git a/server/helpers/middlewares/index.ts b/server/helpers/middlewares/index.ts
index f91aeaa12..f57f3ad31 100644
--- a/server/helpers/middlewares/index.ts
+++ b/server/helpers/middlewares/index.ts
@@ -1,5 +1,5 @@
1export * from './abuses'
1export * from './accounts' 2export * from './accounts'
2export * from './video-abuses'
3export * from './video-blacklists' 3export * from './video-blacklists'
4export * from './video-captions' 4export * from './video-captions'
5export * from './video-channels' 5export * from './video-channels'
diff --git a/server/helpers/middlewares/video-abuses.ts b/server/helpers/middlewares/video-abuses.ts
deleted file mode 100644
index 97a5724b6..000000000
--- a/server/helpers/middlewares/video-abuses.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { Response } from 'express'
2import { VideoAbuseModel } from '../../models/video/video-abuse'
3import { fetchVideo } from '../video'
4
5async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
6 const abuseId = parseInt(abuseIdArg + '', 10)
7 let videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
8
9 if (!videoAbuse) {
10 const userId = res.locals.oauth?.token.User.id
11 const video = await fetchVideo(videoUUID, 'all', userId)
12
13 if (video) videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, video.id)
14 }
15
16 if (videoAbuse === null) {
17 res.status(404)
18 .json({ error: 'Video abuse not found' })
19 .end()
20
21 return false
22 }
23
24 res.locals.videoAbuse = videoAbuse
25 return true
26}
27
28// ---------------------------------------------------------------------------
29
30export {
31 doesVideoAbuseExist
32}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index e730e3c84..2e9d3956e 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -1,9 +1,17 @@
1import { join } from 'path' 1import { join } from 'path'
2import { randomBytes } from 'crypto' 2import { randomBytes } from 'crypto'
3import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models'
4import { ActivityPubActorType } from '../../shared/models/activitypub' 3import { ActivityPubActorType } from '../../shared/models/activitypub'
5import { FollowState } from '../../shared/models/actors' 4import { FollowState } from '../../shared/models/actors'
6import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos' 5import {
6 AbuseState,
7 VideoImportState,
8 VideoPrivacy,
9 VideoTranscodingFPS,
10 JobType,
11 VideoRateType,
12 VideoResolution,
13 VideoState
14} from '../../shared/models'
7// Do not use barrels, remain constants as independent as possible 15// Do not use barrels, remain constants as independent as possible
8import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils' 16import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils'
9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 17import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@@ -15,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
15 23
16// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
17 25
18const LAST_MIGRATION_VERSION = 515 26const LAST_MIGRATION_VERSION = 520
19 27
20// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
21 29
@@ -51,7 +59,6 @@ const SORTABLE_COLUMNS = {
51 USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], 59 USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
52 ACCOUNTS: [ 'createdAt' ], 60 ACCOUNTS: [ 'createdAt' ],
53 JOBS: [ 'createdAt' ], 61 JOBS: [ 'createdAt' ],
54 VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ],
55 VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], 62 VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
56 VIDEO_IMPORTS: [ 'createdAt' ], 63 VIDEO_IMPORTS: [ 'createdAt' ],
57 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], 64 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
@@ -66,6 +73,8 @@ const SORTABLE_COLUMNS = {
66 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], 73 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
67 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], 74 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
68 75
76 ABUSES: [ 'id', 'createdAt', 'state' ],
77
69 ACCOUNTS_BLOCKLIST: [ 'createdAt' ], 78 ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
70 SERVERS_BLOCKLIST: [ 'createdAt' ], 79 SERVERS_BLOCKLIST: [ 'createdAt' ],
71 80
@@ -193,7 +202,7 @@ const CONSTRAINTS_FIELDS = {
193 VIDEO_LANGUAGES: { max: 500 }, // Array length 202 VIDEO_LANGUAGES: { max: 500 }, // Array length
194 BLOCKED_REASON: { min: 3, max: 250 } // Length 203 BLOCKED_REASON: { min: 3, max: 250 } // Length
195 }, 204 },
196 VIDEO_ABUSES: { 205 ABUSES: {
197 REASON: { min: 2, max: 3000 }, // Length 206 REASON: { min: 2, max: 3000 }, // Length
198 MODERATION_COMMENT: { min: 2, max: 3000 } // Length 207 MODERATION_COMMENT: { min: 2, max: 3000 } // Length
199 }, 208 },
@@ -378,10 +387,10 @@ const VIDEO_IMPORT_STATES = {
378 [VideoImportState.REJECTED]: 'Rejected' 387 [VideoImportState.REJECTED]: 'Rejected'
379} 388}
380 389
381const VIDEO_ABUSE_STATES = { 390const ABUSE_STATES = {
382 [VideoAbuseState.PENDING]: 'Pending', 391 [AbuseState.PENDING]: 'Pending',
383 [VideoAbuseState.REJECTED]: 'Rejected', 392 [AbuseState.REJECTED]: 'Rejected',
384 [VideoAbuseState.ACCEPTED]: 'Accepted' 393 [AbuseState.ACCEPTED]: 'Accepted'
385} 394}
386 395
387const VIDEO_PLAYLIST_PRIVACIES = { 396const VIDEO_PLAYLIST_PRIVACIES = {
@@ -778,7 +787,7 @@ export {
778 VIDEO_RATE_TYPES, 787 VIDEO_RATE_TYPES,
779 VIDEO_TRANSCODING_FPS, 788 VIDEO_TRANSCODING_FPS,
780 FFMPEG_NICE, 789 FFMPEG_NICE,
781 VIDEO_ABUSE_STATES, 790 ABUSE_STATES,
782 VIDEO_CHANNELS, 791 VIDEO_CHANNELS,
783 LRU_CACHE, 792 LRU_CACHE,
784 JOB_REQUEST_TIMEOUT, 793 JOB_REQUEST_TIMEOUT,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 633d4f956..0775f1fad 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -1,44 +1,45 @@
1import { QueryTypes, Transaction } from 'sequelize'
1import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' 2import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
3import { AbuseModel } from '@server/models/abuse/abuse'
4import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
5import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
2import { isTestInstance } from '../helpers/core-utils' 6import { isTestInstance } from '../helpers/core-utils'
3import { logger } from '../helpers/logger' 7import { logger } from '../helpers/logger'
4
5import { AccountModel } from '../models/account/account' 8import { AccountModel } from '../models/account/account'
9import { AccountBlocklistModel } from '../models/account/account-blocklist'
6import { AccountVideoRateModel } from '../models/account/account-video-rate' 10import { AccountVideoRateModel } from '../models/account/account-video-rate'
7import { UserModel } from '../models/account/user' 11import { UserModel } from '../models/account/user'
12import { UserNotificationModel } from '../models/account/user-notification'
13import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
14import { UserVideoHistoryModel } from '../models/account/user-video-history'
8import { ActorModel } from '../models/activitypub/actor' 15import { ActorModel } from '../models/activitypub/actor'
9import { ActorFollowModel } from '../models/activitypub/actor-follow' 16import { ActorFollowModel } from '../models/activitypub/actor-follow'
10import { ApplicationModel } from '../models/application/application' 17import { ApplicationModel } from '../models/application/application'
11import { AvatarModel } from '../models/avatar/avatar' 18import { AvatarModel } from '../models/avatar/avatar'
12import { OAuthClientModel } from '../models/oauth/oauth-client' 19import { OAuthClientModel } from '../models/oauth/oauth-client'
13import { OAuthTokenModel } from '../models/oauth/oauth-token' 20import { OAuthTokenModel } from '../models/oauth/oauth-token'
21import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
22import { PluginModel } from '../models/server/plugin'
14import { ServerModel } from '../models/server/server' 23import { ServerModel } from '../models/server/server'
24import { ServerBlocklistModel } from '../models/server/server-blocklist'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
15import { TagModel } from '../models/video/tag' 26import { TagModel } from '../models/video/tag'
27import { ThumbnailModel } from '../models/video/thumbnail'
16import { VideoModel } from '../models/video/video' 28import { VideoModel } from '../models/video/video'
17import { VideoAbuseModel } from '../models/video/video-abuse'
18import { VideoBlacklistModel } from '../models/video/video-blacklist' 29import { VideoBlacklistModel } from '../models/video/video-blacklist'
30import { VideoCaptionModel } from '../models/video/video-caption'
31import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
19import { VideoChannelModel } from '../models/video/video-channel' 32import { VideoChannelModel } from '../models/video/video-channel'
20import { VideoCommentModel } from '../models/video/video-comment' 33import { VideoCommentModel } from '../models/video/video-comment'
21import { VideoFileModel } from '../models/video/video-file' 34import { VideoFileModel } from '../models/video/video-file'
22import { VideoShareModel } from '../models/video/video-share'
23import { VideoTagModel } from '../models/video/video-tag'
24import { CONFIG } from './config'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
26import { VideoCaptionModel } from '../models/video/video-caption'
27import { VideoImportModel } from '../models/video/video-import' 35import { VideoImportModel } from '../models/video/video-import'
28import { VideoViewModel } from '../models/video/video-view'
29import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
30import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
31import { UserVideoHistoryModel } from '../models/account/user-video-history'
32import { AccountBlocklistModel } from '../models/account/account-blocklist'
33import { ServerBlocklistModel } from '../models/server/server-blocklist'
34import { UserNotificationModel } from '../models/account/user-notification'
35import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
37import { VideoPlaylistModel } from '../models/video/video-playlist' 36import { VideoPlaylistModel } from '../models/video/video-playlist'
38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' 37import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
39import { ThumbnailModel } from '../models/video/thumbnail' 38import { VideoShareModel } from '../models/video/video-share'
40import { PluginModel } from '../models/server/plugin' 39import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
41import { QueryTypes, Transaction } from 'sequelize' 40import { VideoTagModel } from '../models/video/video-tag'
41import { VideoViewModel } from '../models/video/video-view'
42import { CONFIG } from './config'
42 43
43require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 44require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
44 45
@@ -86,6 +87,8 @@ async function initDatabaseModels (silent: boolean) {
86 TagModel, 87 TagModel,
87 AccountVideoRateModel, 88 AccountVideoRateModel,
88 UserModel, 89 UserModel,
90 AbuseModel,
91 VideoCommentAbuseModel,
89 VideoAbuseModel, 92 VideoAbuseModel,
90 VideoModel, 93 VideoModel,
91 VideoChangeOwnershipModel, 94 VideoChangeOwnershipModel,
diff --git a/server/initializers/migrations/0250-video-abuse-state.ts b/server/initializers/migrations/0250-video-abuse-state.ts
index 50de25182..e4993c393 100644
--- a/server/initializers/migrations/0250-video-abuse-state.ts
+++ b/server/initializers/migrations/0250-video-abuse-state.ts
@@ -1,5 +1,5 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { VideoAbuseState } from '../../../shared/models/videos' 2import { AbuseState } from '../../../shared/models'
3 3
4async function up (utils: { 4async function up (utils: {
5 transaction: Sequelize.Transaction 5 transaction: Sequelize.Transaction
@@ -16,7 +16,7 @@ async function up (utils: {
16 } 16 }
17 17
18 { 18 {
19 const query = 'UPDATE "videoAbuse" SET "state" = ' + VideoAbuseState.PENDING 19 const query = 'UPDATE "videoAbuse" SET "state" = ' + AbuseState.PENDING
20 await utils.sequelize.query(query) 20 await utils.sequelize.query(query)
21 } 21 }
22 22
diff --git a/server/initializers/migrations/0470-cleaup-indexes.ts b/server/initializers/migrations/0470-cleanup-indexes.ts
index 7365c30f8..7365c30f8 100644
--- a/server/initializers/migrations/0470-cleaup-indexes.ts
+++ b/server/initializers/migrations/0470-cleanup-indexes.ts
diff --git a/server/initializers/migrations/0520-abuses-split.ts b/server/initializers/migrations/0520-abuses-split.ts
new file mode 100644
index 000000000..b02a21989
--- /dev/null
+++ b/server/initializers/migrations/0520-abuses-split.ts
@@ -0,0 +1,90 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 await utils.queryInterface.renameTable('videoAbuse', 'abuse')
9
10 await utils.sequelize.query(`
11 ALTER TABLE "abuse"
12 ADD COLUMN "flaggedAccountId" INTEGER REFERENCES "account" ("id") ON DELETE SET NULL ON UPDATE CASCADE
13 `)
14
15 await utils.sequelize.query(`
16 UPDATE "abuse" SET "videoId" = NULL
17 WHERE "videoId" NOT IN (SELECT "id" FROM "video")
18 `)
19
20 await utils.sequelize.query(`
21 UPDATE "abuse" SET "flaggedAccountId" = "videoChannel"."accountId"
22 FROM "video" INNER JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"
23 WHERE "abuse"."videoId" = "video"."id"
24 `)
25
26 await utils.sequelize.query('DROP INDEX IF EXISTS video_abuse_video_id;')
27 await utils.sequelize.query('DROP INDEX IF EXISTS video_abuse_reporter_account_id;')
28
29 await utils.sequelize.query(`
30 CREATE TABLE IF NOT EXISTS "videoAbuse" (
31 "id" serial,
32 "startAt" integer DEFAULT NULL,
33 "endAt" integer DEFAULT NULL,
34 "deletedVideo" jsonb DEFAULT NULL,
35 "abuseId" integer NOT NULL REFERENCES "abuse" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
36 "videoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
37 "createdAt" TIMESTAMP WITH time zone NOT NULL,
38 "updatedAt" timestamp WITH time zone NOT NULL,
39 PRIMARY KEY ("id")
40 );
41 `)
42
43 await utils.sequelize.query(`
44 CREATE TABLE IF NOT EXISTS "commentAbuse" (
45 "id" serial,
46 "abuseId" integer NOT NULL REFERENCES "abuse" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
47 "videoCommentId" integer REFERENCES "videoComment" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
48 "createdAt" timestamp WITH time zone NOT NULL,
49 "updatedAt" timestamp WITH time zone NOT NULL,
50 PRIMARY KEY ("id")
51 );
52 `)
53
54 await utils.sequelize.query(`
55 INSERT INTO "videoAbuse" ("startAt", "endAt", "deletedVideo", "abuseId", "videoId", "createdAt", "updatedAt")
56 SELECT "abuse"."startAt", "abuse"."endAt", "abuse"."deletedVideo", "abuse"."id", "abuse"."videoId",
57 "abuse"."createdAt", "abuse"."updatedAt"
58 FROM "abuse"
59 `)
60
61 await utils.queryInterface.removeColumn('abuse', 'startAt')
62 await utils.queryInterface.removeColumn('abuse', 'endAt')
63 await utils.queryInterface.removeColumn('abuse', 'deletedVideo')
64 await utils.queryInterface.removeColumn('abuse', 'videoId')
65
66 await utils.sequelize.query('DROP INDEX IF EXISTS user_notification_video_abuse_id')
67 await utils.queryInterface.renameColumn('userNotification', 'videoAbuseId', 'abuseId')
68 await utils.sequelize.query(
69 'ALTER TABLE "userNotification" RENAME CONSTRAINT "userNotification_videoAbuseId_fkey" TO "userNotification_abuseId_fkey"'
70 )
71
72 await utils.sequelize.query(
73 'ALTER TABLE "abuse" RENAME CONSTRAINT "videoAbuse_reporterAccountId_fkey" TO "abuse_reporterAccountId_fkey"'
74 )
75
76 await utils.sequelize.query(
77 'ALTER INDEX IF EXISTS "videoAbuse_pkey" RENAME TO "abuse_pkey"'
78 )
79
80 await utils.queryInterface.renameColumn('userNotificationSetting', 'videoAbuseAsModerator', 'abuseAsModerator')
81}
82
83function down (options) {
84 throw new Error('Not implemented.')
85}
86
87export {
88 up,
89 down
90}
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts
index 1d7132a3a..6350cee12 100644
--- a/server/lib/activitypub/process/process-flag.ts
+++ b/server/lib/activitypub/process/process-flag.ts
@@ -1,24 +1,19 @@
1import { 1import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
2 ActivityCreate, 2import { AccountModel } from '@server/models/account/account'
3 ActivityFlag, 3import { VideoModel } from '@server/models/video/video'
4 VideoAbuseState, 4import { VideoCommentModel } from '@server/models/video/video-comment'
5 videoAbusePredefinedReasonsMap 5import { AbuseObject, abusePredefinedReasonsMap, AbuseState, ActivityCreate, ActivityFlag } from '../../../../shared'
6} from '../../../../shared' 6import { getAPId } from '../../../helpers/activitypub'
7import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
8import { retryTransactionWrapper } from '../../../helpers/database-utils' 7import { retryTransactionWrapper } from '../../../helpers/database-utils'
9import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
10import { sequelizeTypescript } from '../../../initializers/database' 9import { sequelizeTypescript } from '../../../initializers/database'
11import { VideoAbuseModel } from '../../../models/video/video-abuse'
12import { getOrCreateVideoAndAccountAndChannel } from '../videos'
13import { Notifier } from '../../notifier'
14import { getAPId } from '../../../helpers/activitypub'
15import { APProcessorOptions } from '../../../types/activitypub-processor.model' 10import { APProcessorOptions } from '../../../types/activitypub-processor.model'
16import { MActorSignature, MVideoAbuseAccountVideo } from '../../../types/models' 11import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models'
17import { AccountModel } from '@server/models/account/account'
18 12
19async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) { 13async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) {
20 const { activity, byActor } = options 14 const { activity, byActor } = options
21 return retryTransactionWrapper(processCreateVideoAbuse, activity, byActor) 15
16 return retryTransactionWrapper(processCreateAbuse, activity, byActor)
22} 17}
23 18
24// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
@@ -29,55 +24,79 @@ export {
29 24
30// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
31 26
32async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) { 27async function processCreateAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) {
33 const flag = activity.type === 'Flag' ? activity : (activity.object as VideoAbuseObject) 28 const flag = activity.type === 'Flag' ? activity : (activity.object as AbuseObject)
34 29
35 const account = byActor.Account 30 const account = byActor.Account
36 if (!account) throw new Error('Cannot create video abuse with the non account actor ' + byActor.url) 31 if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
32
33 const reporterAccount = await AccountModel.load(account.id)
37 34
38 const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ] 35 const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ]
39 36
37 const tags = Array.isArray(flag.tag) ? flag.tag : []
38 const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name])
39 .filter(v => !isNaN(v))
40
41 const startAt = flag.startAt
42 const endAt = flag.endAt
43
40 for (const object of objects) { 44 for (const object of objects) {
41 try { 45 try {
42 logger.debug('Reporting remote abuse for video %s.', getAPId(object)) 46 const uri = getAPId(object)
43
44 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object })
45 const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t))
46 const tags = Array.isArray(flag.tag) ? flag.tag : []
47 const predefinedReasons = tags.map(tag => videoAbusePredefinedReasonsMap[tag.name])
48 .filter(v => !isNaN(v))
49 const startAt = flag.startAt
50 const endAt = flag.endAt
51
52 const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
53 const videoAbuseData = {
54 reporterAccountId: account.id,
55 reason: flag.content,
56 videoId: video.id,
57 state: VideoAbuseState.PENDING,
58 predefinedReasons,
59 startAt,
60 endAt
61 }
62 47
63 const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t }) 48 logger.debug('Reporting remote abuse for object %s.', uri)
64 videoAbuseInstance.Video = video
65 videoAbuseInstance.Account = reporterAccount
66 49
67 logger.info('Remote abuse for video uuid %s created', flag.object) 50 await sequelizeTypescript.transaction(async t => {
68 51
69 return videoAbuseInstance 52 const video = await VideoModel.loadByUrlAndPopulateAccount(uri)
70 }) 53 let videoComment: MCommentOwnerVideo
54 let flaggedAccount: MAccountDefault
55
56 if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri)
57 if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri)
58
59 if (!video && !videoComment && !flaggedAccount) {
60 logger.warn('Cannot flag unknown entity %s.', object)
61 return
62 }
63
64 const baseAbuse = {
65 reporterAccountId: reporterAccount.id,
66 reason: flag.content,
67 state: AbuseState.PENDING,
68 predefinedReasons
69 }
71 70
72 const videoAbuseJSON = videoAbuseInstance.toFormattedJSON() 71 if (video) {
72 return createVideoAbuse({
73 baseAbuse,
74 startAt,
75 endAt,
76 reporterAccount,
77 transaction: t,
78 videoInstance: video
79 })
80 }
81
82 if (videoComment) {
83 return createVideoCommentAbuse({
84 baseAbuse,
85 reporterAccount,
86 transaction: t,
87 commentInstance: videoComment
88 })
89 }
73 90
74 Notifier.Instance.notifyOnNewVideoAbuse({ 91 return await createAccountAbuse({
75 videoAbuse: videoAbuseJSON, 92 baseAbuse,
76 videoAbuseInstance, 93 reporterAccount,
77 reporter: reporterAccount.Actor.getIdentifier() 94 transaction: t,
95 accountInstance: flaggedAccount
96 })
78 }) 97 })
79 } catch (err) { 98 } catch (err) {
80 logger.debug('Cannot process report of %s. (Maybe not a video abuse).', getAPId(object), { err }) 99 logger.debug('Cannot process report of %s', getAPId(object), { err })
81 } 100 }
82 } 101 }
83} 102}
diff --git a/server/lib/activitypub/send/send-flag.ts b/server/lib/activitypub/send/send-flag.ts
index 3a1fe0812..821637ec8 100644
--- a/server/lib/activitypub/send/send-flag.ts
+++ b/server/lib/activitypub/send/send-flag.ts
@@ -1,32 +1,31 @@
1import { getVideoAbuseActivityPubUrl } from '../url' 1import { Transaction } from 'sequelize'
2import { unicastTo } from './utils'
3import { logger } from '../../../helpers/logger'
4import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub' 2import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
3import { logger } from '../../../helpers/logger'
4import { MAbuseAP, MAccountLight, MActor } from '../../../types/models'
5import { audiencify, getAudience } from '../audience' 5import { audiencify, getAudience } from '../audience'
6import { Transaction } from 'sequelize' 6import { getAbuseActivityPubUrl } from '../url'
7import { MActor, MVideoFullLight } from '../../../types/models' 7import { unicastTo } from './utils'
8import { MVideoAbuseVideo } from '../../../types/models/video'
9 8
10function sendVideoAbuse (byActor: MActor, videoAbuse: MVideoAbuseVideo, video: MVideoFullLight, t: Transaction) { 9function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
11 if (!video.VideoChannel.Account.Actor.serverId) return // Local user 10 if (!flaggedAccount.Actor.serverId) return // Local user
12 11
13 const url = getVideoAbuseActivityPubUrl(videoAbuse) 12 const url = getAbuseActivityPubUrl(abuse)
14 13
15 logger.info('Creating job to send video abuse %s.', url) 14 logger.info('Creating job to send abuse %s.', url)
16 15
17 // Custom audience, we only send the abuse to the origin instance 16 // Custom audience, we only send the abuse to the origin instance
18 const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] } 17 const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
19 const flagActivity = buildFlagActivity(url, byActor, videoAbuse, audience) 18 const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
20 19
21 t.afterCommit(() => unicastTo(flagActivity, byActor, video.VideoChannel.Account.Actor.getSharedInbox())) 20 t.afterCommit(() => unicastTo(flagActivity, byActor, flaggedAccount.Actor.getSharedInbox()))
22} 21}
23 22
24function buildFlagActivity (url: string, byActor: MActor, videoAbuse: MVideoAbuseVideo, audience: ActivityAudience): ActivityFlag { 23function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
25 if (!audience) audience = getAudience(byActor) 24 if (!audience) audience = getAudience(byActor)
26 25
27 const activity = Object.assign( 26 const activity = Object.assign(
28 { id: url, actor: byActor.url }, 27 { id: url, actor: byActor.url },
29 videoAbuse.toActivityPubObject() 28 abuse.toActivityPubObject()
30 ) 29 )
31 30
32 return audiencify(activity, audience) 31 return audiencify(activity, audience)
@@ -35,5 +34,5 @@ function buildFlagActivity (url: string, byActor: MActor, videoAbuse: MVideoAbus
35// --------------------------------------------------------------------------- 34// ---------------------------------------------------------------------------
36 35
37export { 36export {
38 sendVideoAbuse 37 sendAbuse
39} 38}
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
index 7f98751a1..b54e038a4 100644
--- a/server/lib/activitypub/url.ts
+++ b/server/lib/activitypub/url.ts
@@ -5,10 +5,10 @@ import {
5 MActorId, 5 MActorId,
6 MActorUrl, 6 MActorUrl,
7 MCommentId, 7 MCommentId,
8 MVideoAbuseId,
9 MVideoId, 8 MVideoId,
10 MVideoUrl, 9 MVideoUrl,
11 MVideoUUID 10 MVideoUUID,
11 MAbuseId
12} from '../../types/models' 12} from '../../types/models'
13import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist' 13import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist'
14import { MVideoFileVideoUUID } from '../../types/models/video/video-file' 14import { MVideoFileVideoUUID } from '../../types/models/video/video-file'
@@ -48,8 +48,8 @@ function getAccountActivityPubUrl (accountName: string) {
48 return WEBSERVER.URL + '/accounts/' + accountName 48 return WEBSERVER.URL + '/accounts/' + accountName
49} 49}
50 50
51function getVideoAbuseActivityPubUrl (videoAbuse: MVideoAbuseId) { 51function getAbuseActivityPubUrl (abuse: MAbuseId) {
52 return WEBSERVER.URL + '/admin/video-abuses/' + videoAbuse.id 52 return WEBSERVER.URL + '/admin/abuses/' + abuse.id
53} 53}
54 54
55function getVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) { 55function getVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) {
@@ -118,7 +118,7 @@ export {
118 getVideoCacheStreamingPlaylistActivityPubUrl, 118 getVideoCacheStreamingPlaylistActivityPubUrl,
119 getVideoChannelActivityPubUrl, 119 getVideoChannelActivityPubUrl,
120 getAccountActivityPubUrl, 120 getAccountActivityPubUrl,
121 getVideoAbuseActivityPubUrl, 121 getAbuseActivityPubUrl,
122 getActorFollowActivityPubUrl, 122 getActorFollowActivityPubUrl,
123 getActorFollowAcceptActivityPubUrl, 123 getActorFollowAcceptActivityPubUrl,
124 getVideoAnnounceActivityPubUrl, 124 getVideoAnnounceActivityPubUrl,
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index c08732b48..d54eab966 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -1,26 +1,20 @@
1import { readFileSync } from 'fs-extra'
2import { merge } from 'lodash'
1import { createTransport, Transporter } from 'nodemailer' 3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path'
5import { VideoChannelModel } from '@server/models/video/video-channel'
6import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
7import { MVideoImport, MVideoImportVideo } from '@server/types/models/video/video-import'
8import { Abuse, EmailPayload } from '@shared/models'
9import { SendEmailOptions } from '../../shared/models/server/emailer.model'
2import { isTestInstance, root } from '../helpers/core-utils' 10import { isTestInstance, root } from '../helpers/core-utils'
3import { bunyanLogger, logger } from '../helpers/logger' 11import { bunyanLogger, logger } from '../helpers/logger'
4import { CONFIG, isEmailEnabled } from '../initializers/config' 12import { CONFIG, isEmailEnabled } from '../initializers/config'
5import { JobQueue } from './job-queue'
6import { readFileSync } from 'fs-extra'
7import { WEBSERVER } from '../initializers/constants' 13import { WEBSERVER } from '../initializers/constants'
8import { 14import { MAbuseFull, MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
9 MCommentOwnerVideo, 15import { MCommentOwnerVideo, MVideo, MVideoAccountLight } from '../types/models/video'
10 MVideo, 16import { JobQueue } from './job-queue'
11 MVideoAbuseVideo, 17
12 MVideoAccountLight,
13 MVideoBlacklistLightVideo,
14 MVideoBlacklistVideo
15} from '../types/models/video'
16import { MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
17import { MVideoImport, MVideoImportVideo } from '@server/types/models/video/video-import'
18import { EmailPayload } from '@shared/models'
19import { join } from 'path'
20import { VideoAbuse } from '../../shared/models/videos'
21import { SendEmailOptions } from '../../shared/models/server/emailer.model'
22import { merge } from 'lodash'
23import { VideoChannelModel } from '@server/models/video/video-channel'
24const Email = require('email-templates') 18const Email = require('email-templates')
25 19
26class Emailer { 20class Emailer {
@@ -288,28 +282,74 @@ class Emailer {
288 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 282 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
289 } 283 }
290 284
291 addVideoAbuseModeratorsNotification (to: string[], parameters: { 285 addAbuseModeratorsNotification (to: string[], parameters: {
292 videoAbuse: VideoAbuse 286 abuse: Abuse
293 videoAbuseInstance: MVideoAbuseVideo 287 abuseInstance: MAbuseFull
294 reporter: string 288 reporter: string
295 }) { 289 }) {
296 const videoAbuseUrl = WEBSERVER.URL + '/admin/moderation/video-abuses/list?search=%23' + parameters.videoAbuse.id 290 const { abuse, abuseInstance, reporter } = parameters
297 const videoUrl = WEBSERVER.URL + parameters.videoAbuseInstance.Video.getWatchStaticPath()
298 291
299 const emailPayload: EmailPayload = { 292 const action = {
300 template: 'video-abuse-new', 293 text: 'View report #' + abuse.id,
301 to, 294 url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
302 subject: `New video abuse report from ${parameters.reporter}`, 295 }
303 locals: { 296
304 videoUrl, 297 let emailPayload: EmailPayload
305 videoAbuseUrl, 298
306 videoCreatedAt: new Date(parameters.videoAbuseInstance.Video.createdAt).toLocaleString(), 299 if (abuseInstance.VideoAbuse) {
307 videoPublishedAt: new Date(parameters.videoAbuseInstance.Video.publishedAt).toLocaleString(), 300 const video = abuseInstance.VideoAbuse.Video
308 videoAbuse: parameters.videoAbuse, 301 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
309 reporter: parameters.reporter, 302
310 action: { 303 emailPayload = {
311 text: 'View report #' + parameters.videoAbuse.id, 304 template: 'video-abuse-new',
312 url: videoAbuseUrl 305 to,
306 subject: `New video abuse report from ${reporter}`,
307 locals: {
308 videoUrl,
309 isLocal: video.remote === false,
310 videoCreatedAt: new Date(video.createdAt).toLocaleString(),
311 videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
312 videoName: video.name,
313 reason: abuse.reason,
314 videoChannel: abuse.video.channel,
315 reporter,
316 action
317 }
318 }
319 } else if (abuseInstance.VideoCommentAbuse) {
320 const comment = abuseInstance.VideoCommentAbuse.VideoComment
321 const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
322
323 emailPayload = {
324 template: 'video-comment-abuse-new',
325 to,
326 subject: `New comment abuse report from ${reporter}`,
327 locals: {
328 commentUrl,
329 videoName: comment.Video.name,
330 isLocal: comment.isOwned(),
331 commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
332 reason: abuse.reason,
333 flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(),
334 reporter,
335 action
336 }
337 }
338 } else {
339 const account = abuseInstance.FlaggedAccount
340 const accountUrl = account.getClientUrl()
341
342 emailPayload = {
343 template: 'account-abuse-new',
344 to,
345 subject: `New account abuse report from ${reporter}`,
346 locals: {
347 accountUrl,
348 accountDisplayName: account.getDisplayName(),
349 isLocal: account.isOwned(),
350 reason: abuse.reason,
351 reporter,
352 action
313 } 353 }
314 } 354 }
315 } 355 }
diff --git a/server/lib/emails/account-abuse-new/html.pug b/server/lib/emails/account-abuse-new/html.pug
new file mode 100644
index 000000000..f1aa2886e
--- /dev/null
+++ b/server/lib/emails/account-abuse-new/html.pug
@@ -0,0 +1,14 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | An account is pending moderation
6
7block content
8 p
9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}account
10 a(href=accountUrl) #{accountDisplayName}
11
12 p The reporter, #{reporter}, cited the following reason(s):
13 blockquote #{reason}
14 br(style="display: none;")
diff --git a/server/lib/emails/common/mixins.pug b/server/lib/emails/common/mixins.pug
index 76b805a24..831211864 100644
--- a/server/lib/emails/common/mixins.pug
+++ b/server/lib/emails/common/mixins.pug
@@ -1,3 +1,7 @@
1mixin channel(channel) 1mixin channel(channel)
2 - var handle = `${channel.name}@${channel.host}` 2 - var handle = `${channel.name}@${channel.host}`
3 | #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}] \ No newline at end of file 3 | #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]
4
5mixin account(account)
6 - var handle = `${account.name}@${account.host}`
7 | #[a(href=`${WEBSERVER.URL}/accounts/${handle}` title=handle) #{account.displayName}]
diff --git a/server/lib/emails/video-abuse-new/html.pug b/server/lib/emails/video-abuse-new/html.pug
index 999c89d26..a1acdabdc 100644
--- a/server/lib/emails/video-abuse-new/html.pug
+++ b/server/lib/emails/video-abuse-new/html.pug
@@ -6,13 +6,13 @@ block title
6 6
7block content 7block content
8 p 8 p
9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{videoAbuse.video.channel.isLocal ? '' : 'remote '}video " 9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}video "
10 a(href=videoUrl) #{videoAbuse.video.name} 10 a(href=videoUrl) #{videoName}
11 | " by #[+channel(videoAbuse.video.channel)] 11 | " by #[+channel(videoChannel)]
12 if videoPublishedAt 12 if videoPublishedAt
13 | , published the #{videoPublishedAt}. 13 | , published the #{videoPublishedAt}.
14 else 14 else
15 | , uploaded the #{videoCreatedAt} but not yet published. 15 | , uploaded the #{videoCreatedAt} but not yet published.
16 p The reporter, #{reporter}, cited the following reason(s): 16 p The reporter, #{reporter}, cited the following reason(s):
17 blockquote #{videoAbuse.reason} 17 blockquote #{reason}
18 br(style="display: none;") 18 br(style="display: none;")
diff --git a/server/lib/emails/video-comment-abuse-new/html.pug b/server/lib/emails/video-comment-abuse-new/html.pug
new file mode 100644
index 000000000..e92d986b5
--- /dev/null
+++ b/server/lib/emails/video-comment-abuse-new/html.pug
@@ -0,0 +1,16 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | A comment is pending moderation
6
7block content
8 p
9 | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}
10 a(href=commentUrl) comment on video "#{videoName}"
11 | of #{flaggedAccount}
12 | created on #{commentCreatedAt}
13
14 p The reporter, #{reporter}, cited the following reason(s):
15 blockquote #{reason}
16 br(style="display: none;")
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
index 60d1b4053..4fc9cd747 100644
--- a/server/lib/moderation.ts
+++ b/server/lib/moderation.ts
@@ -1,15 +1,33 @@
1import { VideoModel } from '../models/video/video' 1import { PathLike } from 'fs-extra'
2import { VideoCommentModel } from '../models/video/video-comment' 2import { Transaction } from 'sequelize/types'
3import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model' 3import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger'
4import { logger } from '@server/helpers/logger'
5import { AbuseModel } from '@server/models/abuse/abuse'
6import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
7import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
8import { VideoFileModel } from '@server/models/video/video-file'
9import { FilteredModelAttributes } from '@server/types'
10import {
11 MAbuseFull,
12 MAccountDefault,
13 MAccountLight,
14 MCommentAbuseAccountVideo,
15 MCommentOwnerVideo,
16 MUser,
17 MVideoAbuseVideoFull,
18 MVideoAccountLightBlacklistAllFiles
19} from '@server/types/models'
20import { ActivityCreate } from '../../shared/models/activitypub'
21import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
22import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
4import { VideoCreate, VideoImportCreate } from '../../shared/models/videos' 23import { VideoCreate, VideoImportCreate } from '../../shared/models/videos'
24import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
5import { UserModel } from '../models/account/user' 25import { UserModel } from '../models/account/user'
6import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
7import { ActivityCreate } from '../../shared/models/activitypub'
8import { ActorModel } from '../models/activitypub/actor' 26import { ActorModel } from '../models/activitypub/actor'
9import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object' 27import { VideoModel } from '../models/video/video'
10import { VideoFileModel } from '@server/models/video/video-file' 28import { VideoCommentModel } from '../models/video/video-comment'
11import { PathLike } from 'fs-extra' 29import { sendAbuse } from './activitypub/send/send-flag'
12import { MUser } from '@server/types/models' 30import { Notifier } from './notifier'
13 31
14export type AcceptResult = { 32export type AcceptResult = {
15 accepted: boolean 33 accepted: boolean
@@ -73,6 +91,89 @@ function isPostImportVideoAccepted (object: {
73 return { accepted: true } 91 return { accepted: true }
74} 92}
75 93
94async function createVideoAbuse (options: {
95 baseAbuse: FilteredModelAttributes<AbuseModel>
96 videoInstance: MVideoAccountLightBlacklistAllFiles
97 startAt: number
98 endAt: number
99 transaction: Transaction
100 reporterAccount: MAccountDefault
101}) {
102 const { baseAbuse, videoInstance, startAt, endAt, transaction, reporterAccount } = options
103
104 const associateFun = async (abuseInstance: MAbuseFull) => {
105 const videoAbuseInstance: MVideoAbuseVideoFull = await VideoAbuseModel.create({
106 abuseId: abuseInstance.id,
107 videoId: videoInstance.id,
108 startAt: startAt,
109 endAt: endAt
110 }, { transaction })
111
112 videoAbuseInstance.Video = videoInstance
113 abuseInstance.VideoAbuse = videoAbuseInstance
114
115 return { isOwned: videoInstance.isOwned() }
116 }
117
118 return createAbuse({
119 base: baseAbuse,
120 reporterAccount,
121 flaggedAccount: videoInstance.VideoChannel.Account,
122 transaction,
123 associateFun
124 })
125}
126
127function createVideoCommentAbuse (options: {
128 baseAbuse: FilteredModelAttributes<AbuseModel>
129 commentInstance: MCommentOwnerVideo
130 transaction: Transaction
131 reporterAccount: MAccountDefault
132}) {
133 const { baseAbuse, commentInstance, transaction, reporterAccount } = options
134
135 const associateFun = async (abuseInstance: MAbuseFull) => {
136 const commentAbuseInstance: MCommentAbuseAccountVideo = await VideoCommentAbuseModel.create({
137 abuseId: abuseInstance.id,
138 videoCommentId: commentInstance.id
139 }, { transaction })
140
141 commentAbuseInstance.VideoComment = commentInstance
142 abuseInstance.VideoCommentAbuse = commentAbuseInstance
143
144 return { isOwned: commentInstance.isOwned() }
145 }
146
147 return createAbuse({
148 base: baseAbuse,
149 reporterAccount,
150 flaggedAccount: commentInstance.Account,
151 transaction,
152 associateFun
153 })
154}
155
156function createAccountAbuse (options: {
157 baseAbuse: FilteredModelAttributes<AbuseModel>
158 accountInstance: MAccountDefault
159 transaction: Transaction
160 reporterAccount: MAccountDefault
161}) {
162 const { baseAbuse, accountInstance, transaction, reporterAccount } = options
163
164 const associateFun = async () => {
165 return { isOwned: accountInstance.isOwned() }
166 }
167
168 return createAbuse({
169 base: baseAbuse,
170 reporterAccount,
171 flaggedAccount: accountInstance,
172 transaction,
173 associateFun
174 })
175}
176
76export { 177export {
77 isLocalVideoAccepted, 178 isLocalVideoAccepted,
78 isLocalVideoThreadAccepted, 179 isLocalVideoThreadAccepted,
@@ -80,5 +181,48 @@ export {
80 isRemoteVideoCommentAccepted, 181 isRemoteVideoCommentAccepted,
81 isLocalVideoCommentReplyAccepted, 182 isLocalVideoCommentReplyAccepted,
82 isPreImportVideoAccepted, 183 isPreImportVideoAccepted,
83 isPostImportVideoAccepted 184 isPostImportVideoAccepted,
185
186 createAbuse,
187 createVideoAbuse,
188 createVideoCommentAbuse,
189 createAccountAbuse
190}
191
192// ---------------------------------------------------------------------------
193
194async function createAbuse (options: {
195 base: FilteredModelAttributes<AbuseModel>
196 reporterAccount: MAccountDefault
197 flaggedAccount: MAccountLight
198 associateFun: (abuseInstance: MAbuseFull) => Promise<{ isOwned: boolean} >
199 transaction: Transaction
200}) {
201 const { base, reporterAccount, flaggedAccount, associateFun, transaction } = options
202 const auditLogger = auditLoggerFactory('abuse')
203
204 const abuseAttributes = Object.assign({}, base, { flaggedAccountId: flaggedAccount.id })
205 const abuseInstance: MAbuseFull = await AbuseModel.create(abuseAttributes, { transaction })
206
207 abuseInstance.ReporterAccount = reporterAccount
208 abuseInstance.FlaggedAccount = flaggedAccount
209
210 const { isOwned } = await associateFun(abuseInstance)
211
212 if (isOwned === false) {
213 await sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction)
214 }
215
216 const abuseJSON = abuseInstance.toFormattedJSON()
217 auditLogger.create(reporterAccount.Actor.getIdentifier(), new AbuseAuditView(abuseJSON))
218
219 Notifier.Instance.notifyOnNewAbuse({
220 abuse: abuseJSON,
221 abuseInstance,
222 reporter: reporterAccount.Actor.getIdentifier()
223 })
224
225 logger.info('Abuse report %d created.', abuseInstance.id)
226
227 return abuseJSON
84} 228}
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
index 943a087d2..c567e1c20 100644
--- a/server/lib/notifier.ts
+++ b/server/lib/notifier.ts
@@ -8,23 +8,18 @@ import {
8 MUserWithNotificationSetting, 8 MUserWithNotificationSetting,
9 UserNotificationModelForApi 9 UserNotificationModelForApi
10} from '@server/types/models/user' 10} from '@server/types/models/user'
11import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
11import { MVideoImportVideo } from '@server/types/models/video/video-import' 12import { MVideoImportVideo } from '@server/types/models/video/video-import'
13import { Abuse } from '@shared/models'
12import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users' 14import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
13import { VideoAbuse, VideoPrivacy, VideoState } from '../../shared/models/videos' 15import { VideoPrivacy, VideoState } from '../../shared/models/videos'
14import { logger } from '../helpers/logger' 16import { logger } from '../helpers/logger'
15import { CONFIG } from '../initializers/config' 17import { CONFIG } from '../initializers/config'
16import { AccountBlocklistModel } from '../models/account/account-blocklist' 18import { AccountBlocklistModel } from '../models/account/account-blocklist'
17import { UserModel } from '../models/account/user' 19import { UserModel } from '../models/account/user'
18import { UserNotificationModel } from '../models/account/user-notification' 20import { UserNotificationModel } from '../models/account/user-notification'
19import { MAccountServer, MActorFollowFull } from '../types/models' 21import { MAbuseFull, MAccountServer, MActorFollowFull } from '../types/models'
20import { 22import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video'
21 MCommentOwnerVideo,
22 MVideoAbuseVideo,
23 MVideoAccountLight,
24 MVideoBlacklistLightVideo,
25 MVideoBlacklistVideo,
26 MVideoFullLight
27} from '../types/models/video'
28import { isBlockedByServerOrAccount } from './blocklist' 23import { isBlockedByServerOrAccount } from './blocklist'
29import { Emailer } from './emailer' 24import { Emailer } from './emailer'
30import { PeerTubeSocket } from './peertube-socket' 25import { PeerTubeSocket } from './peertube-socket'
@@ -78,9 +73,9 @@ class Notifier {
78 .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err })) 73 .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
79 } 74 }
80 75
81 notifyOnNewVideoAbuse (parameters: { videoAbuse: VideoAbuse, videoAbuseInstance: MVideoAbuseVideo, reporter: string }): void { 76 notifyOnNewAbuse (parameters: { abuse: Abuse, abuseInstance: MAbuseFull, reporter: string }): void {
82 this.notifyModeratorsOfNewVideoAbuse(parameters) 77 this.notifyModeratorsOfNewAbuse(parameters)
83 .catch(err => logger.error('Cannot notify of new video abuse of video %s.', parameters.videoAbuseInstance.Video.url, { err })) 78 .catch(err => logger.error('Cannot notify of new abuse %d.', parameters.abuseInstance.id, { err }))
84 } 79 }
85 80
86 notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void { 81 notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
@@ -354,33 +349,39 @@ class Notifier {
354 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender }) 349 return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
355 } 350 }
356 351
357 private async notifyModeratorsOfNewVideoAbuse (parameters: { 352 private async notifyModeratorsOfNewAbuse (parameters: {
358 videoAbuse: VideoAbuse 353 abuse: Abuse
359 videoAbuseInstance: MVideoAbuseVideo 354 abuseInstance: MAbuseFull
360 reporter: string 355 reporter: string
361 }) { 356 }) {
362 const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES) 357 const { abuse, abuseInstance } = parameters
358
359 const moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
363 if (moderators.length === 0) return 360 if (moderators.length === 0) return
364 361
365 logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, parameters.videoAbuseInstance.Video.url) 362 const url = abuseInstance.VideoAbuse?.Video?.url ||
363 abuseInstance.VideoCommentAbuse?.VideoComment?.url ||
364 abuseInstance.FlaggedAccount.Actor.url
365
366 logger.info('Notifying %s user/moderators of new abuse %s.', moderators.length, url)
366 367
367 function settingGetter (user: MUserWithNotificationSetting) { 368 function settingGetter (user: MUserWithNotificationSetting) {
368 return user.NotificationSetting.videoAbuseAsModerator 369 return user.NotificationSetting.abuseAsModerator
369 } 370 }
370 371
371 async function notificationCreator (user: MUserWithNotificationSetting) { 372 async function notificationCreator (user: MUserWithNotificationSetting) {
372 const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({ 373 const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
373 type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS, 374 type: UserNotificationType.NEW_ABUSE_FOR_MODERATORS,
374 userId: user.id, 375 userId: user.id,
375 videoAbuseId: parameters.videoAbuse.id 376 abuseId: abuse.id
376 }) 377 })
377 notification.VideoAbuse = parameters.videoAbuseInstance 378 notification.Abuse = abuseInstance
378 379
379 return notification 380 return notification
380 } 381 }
381 382
382 function emailSender (emails: string[]) { 383 function emailSender (emails: string[]) {
383 return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, parameters) 384 return Emailer.Instance.addAbuseModeratorsNotification(emails, parameters)
384 } 385 }
385 386
386 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender }) 387 return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 43eef8ab1..642549879 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -133,7 +133,7 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
133 newCommentOnMyVideo: UserNotificationSettingValue.WEB, 133 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
134 myVideoImportFinished: UserNotificationSettingValue.WEB, 134 myVideoImportFinished: UserNotificationSettingValue.WEB,
135 myVideoPublished: UserNotificationSettingValue.WEB, 135 myVideoPublished: UserNotificationSettingValue.WEB,
136 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 136 abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
137 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 137 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
138 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 138 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
139 newUserRegistration: UserNotificationSettingValue.WEB, 139 newUserRegistration: UserNotificationSettingValue.WEB,
diff --git a/server/middlewares/validators/abuse.ts b/server/middlewares/validators/abuse.ts
new file mode 100644
index 000000000..966d1f7fb
--- /dev/null
+++ b/server/middlewares/validators/abuse.ts
@@ -0,0 +1,277 @@
1import * as express from 'express'
2import { body, param, query } from 'express-validator'
3import {
4 isAbuseFilterValid,
5 isAbuseModerationCommentValid,
6 isAbusePredefinedReasonsValid,
7 isAbusePredefinedReasonValid,
8 isAbuseReasonValid,
9 isAbuseStateValid,
10 isAbuseTimestampCoherent,
11 isAbuseTimestampValid,
12 isAbuseVideoIsValid
13} from '@server/helpers/custom-validators/abuses'
14import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '@server/helpers/custom-validators/misc'
15import { doesCommentIdExist } from '@server/helpers/custom-validators/video-comments'
16import { logger } from '@server/helpers/logger'
17import { doesAbuseExist, doesAccountIdExist, doesVideoAbuseExist, doesVideoExist } from '@server/helpers/middlewares'
18import { AbuseCreate } from '@shared/models'
19import { areValidationErrors } from './utils'
20
21const abuseReportValidator = [
22 body('account.id')
23 .optional()
24 .custom(isIdValid)
25 .withMessage('Should have a valid accountId'),
26
27 body('video.id')
28 .optional()
29 .custom(isIdOrUUIDValid)
30 .withMessage('Should have a valid videoId'),
31 body('video.startAt')
32 .optional()
33 .customSanitizer(toIntOrNull)
34 .custom(isAbuseTimestampValid)
35 .withMessage('Should have valid starting time value'),
36 body('video.endAt')
37 .optional()
38 .customSanitizer(toIntOrNull)
39 .custom(isAbuseTimestampValid)
40 .withMessage('Should have valid ending time value')
41 .bail()
42 .custom(isAbuseTimestampCoherent)
43 .withMessage('Should have a startAt timestamp beginning before endAt'),
44
45 body('comment.id')
46 .optional()
47 .custom(isIdValid)
48 .withMessage('Should have a valid commentId'),
49
50 body('reason')
51 .custom(isAbuseReasonValid)
52 .withMessage('Should have a valid reason'),
53
54 body('predefinedReasons')
55 .optional()
56 .custom(isAbusePredefinedReasonsValid)
57 .withMessage('Should have a valid list of predefined reasons'),
58
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 logger.debug('Checking abuseReport parameters', { parameters: req.body })
61
62 if (areValidationErrors(req, res)) return
63
64 const body: AbuseCreate = req.body
65
66 if (body.video?.id && !await doesVideoExist(body.video.id, res)) return
67 if (body.account?.id && !await doesAccountIdExist(body.account.id, res)) return
68 if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return
69
70 if (!body.video?.id && !body.account?.id && !body.comment?.id) {
71 res.status(400)
72 .json({ error: 'video id or account id or comment id is required.' })
73
74 return
75 }
76
77 return next()
78 }
79]
80
81const abuseGetValidator = [
82 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
83
84 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
85 logger.debug('Checking abuseGetValidator parameters', { parameters: req.body })
86
87 if (areValidationErrors(req, res)) return
88 if (!await doesAbuseExist(req.params.id, res)) return
89
90 return next()
91 }
92]
93
94const abuseUpdateValidator = [
95 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
96
97 body('state')
98 .optional()
99 .custom(isAbuseStateValid).withMessage('Should have a valid abuse state'),
100 body('moderationComment')
101 .optional()
102 .custom(isAbuseModerationCommentValid).withMessage('Should have a valid moderation comment'),
103
104 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
105 logger.debug('Checking abuseUpdateValidator parameters', { parameters: req.body })
106
107 if (areValidationErrors(req, res)) return
108 if (!await doesAbuseExist(req.params.id, res)) return
109
110 return next()
111 }
112]
113
114const abuseListValidator = [
115 query('id')
116 .optional()
117 .custom(isIdValid).withMessage('Should have a valid id'),
118 query('filter')
119 .optional()
120 .custom(isAbuseFilterValid)
121 .withMessage('Should have a valid filter'),
122 query('predefinedReason')
123 .optional()
124 .custom(isAbusePredefinedReasonValid)
125 .withMessage('Should have a valid predefinedReason'),
126 query('search')
127 .optional()
128 .custom(exists).withMessage('Should have a valid search'),
129 query('state')
130 .optional()
131 .custom(isAbuseStateValid).withMessage('Should have a valid abuse state'),
132 query('videoIs')
133 .optional()
134 .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
135 query('searchReporter')
136 .optional()
137 .custom(exists).withMessage('Should have a valid reporter search'),
138 query('searchReportee')
139 .optional()
140 .custom(exists).withMessage('Should have a valid reportee search'),
141 query('searchVideo')
142 .optional()
143 .custom(exists).withMessage('Should have a valid video search'),
144 query('searchVideoChannel')
145 .optional()
146 .custom(exists).withMessage('Should have a valid video channel search'),
147
148 (req: express.Request, res: express.Response, next: express.NextFunction) => {
149 logger.debug('Checking abuseListValidator parameters', { parameters: req.body })
150
151 if (areValidationErrors(req, res)) return
152
153 return next()
154 }
155]
156
157// FIXME: deprecated in 2.3. Remove these validators
158
159const videoAbuseReportValidator = [
160 param('videoId')
161 .custom(isIdOrUUIDValid)
162 .not()
163 .isEmpty()
164 .withMessage('Should have a valid videoId'),
165 body('reason')
166 .custom(isAbuseReasonValid)
167 .withMessage('Should have a valid reason'),
168 body('predefinedReasons')
169 .optional()
170 .custom(isAbusePredefinedReasonsValid)
171 .withMessage('Should have a valid list of predefined reasons'),
172 body('startAt')
173 .optional()
174 .customSanitizer(toIntOrNull)
175 .custom(isAbuseTimestampValid)
176 .withMessage('Should have valid starting time value'),
177 body('endAt')
178 .optional()
179 .customSanitizer(toIntOrNull)
180 .custom(isAbuseTimestampValid)
181 .withMessage('Should have valid ending time value'),
182
183 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
184 logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
185
186 if (areValidationErrors(req, res)) return
187 if (!await doesVideoExist(req.params.videoId, res)) return
188
189 return next()
190 }
191]
192
193const videoAbuseGetValidator = [
194 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
195 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
196
197 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
198 logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
199
200 if (areValidationErrors(req, res)) return
201 if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
202
203 return next()
204 }
205]
206
207const videoAbuseUpdateValidator = [
208 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
209 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
210 body('state')
211 .optional()
212 .custom(isAbuseStateValid).withMessage('Should have a valid video abuse state'),
213 body('moderationComment')
214 .optional()
215 .custom(isAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'),
216
217 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
218 logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
219
220 if (areValidationErrors(req, res)) return
221 if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
222
223 return next()
224 }
225]
226
227const videoAbuseListValidator = [
228 query('id')
229 .optional()
230 .custom(isIdValid).withMessage('Should have a valid id'),
231 query('predefinedReason')
232 .optional()
233 .custom(isAbusePredefinedReasonValid)
234 .withMessage('Should have a valid predefinedReason'),
235 query('search')
236 .optional()
237 .custom(exists).withMessage('Should have a valid search'),
238 query('state')
239 .optional()
240 .custom(isAbuseStateValid).withMessage('Should have a valid video abuse state'),
241 query('videoIs')
242 .optional()
243 .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
244 query('searchReporter')
245 .optional()
246 .custom(exists).withMessage('Should have a valid reporter search'),
247 query('searchReportee')
248 .optional()
249 .custom(exists).withMessage('Should have a valid reportee search'),
250 query('searchVideo')
251 .optional()
252 .custom(exists).withMessage('Should have a valid video search'),
253 query('searchVideoChannel')
254 .optional()
255 .custom(exists).withMessage('Should have a valid video channel search'),
256
257 (req: express.Request, res: express.Response, next: express.NextFunction) => {
258 logger.debug('Checking videoAbuseListValidator parameters', { parameters: req.body })
259
260 if (areValidationErrors(req, res)) return
261
262 return next()
263 }
264]
265
266// ---------------------------------------------------------------------------
267
268export {
269 abuseListValidator,
270 abuseReportValidator,
271 abuseGetValidator,
272 abuseUpdateValidator,
273 videoAbuseReportValidator,
274 videoAbuseGetValidator,
275 videoAbuseUpdateValidator,
276 videoAbuseListValidator
277}
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 65dd00335..4086d77aa 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -1,3 +1,4 @@
1export * from './abuse'
1export * from './account' 2export * from './account'
2export * from './blocklist' 3export * from './blocklist'
3export * from './oembed' 4export * from './oembed'
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index b76dab722..29aba0436 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -5,7 +5,7 @@ import { checkSort, createSortableColumns } from './utils'
5const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS) 5const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS)
6const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS) 6const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS)
7const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS) 7const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
8const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES) 8const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) 9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) 10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) 11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
@@ -28,7 +28,7 @@ const SORTABLE_VIDEO_REDUNDANCIES_COLUMNS = createSortableColumns(SORTABLE_COLUM
28const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 28const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
29const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 29const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
30const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS) 30const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
31const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS) 31const abusesSortValidator = checkSort(SORTABLE_ABUSES_COLUMNS)
32const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS) 32const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
33const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) 33const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
34const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) 34const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
@@ -52,7 +52,7 @@ const videoRedundanciesSortValidator = checkSort(SORTABLE_VIDEO_REDUNDANCIES_COL
52 52
53export { 53export {
54 usersSortValidator, 54 usersSortValidator,
55 videoAbusesSortValidator, 55 abusesSortValidator,
56 videoChannelsSortValidator, 56 videoChannelsSortValidator,
57 videoImportsSortValidator, 57 videoImportsSortValidator,
58 videosSearchSortValidator, 58 videosSearchSortValidator,
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
index fbfcb0a4c..21a7be08d 100644
--- a/server/middlewares/validators/user-notifications.ts
+++ b/server/middlewares/validators/user-notifications.ts
@@ -25,8 +25,8 @@ const updateNotificationSettingsValidator = [
25 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'), 25 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
26 body('newCommentOnMyVideo') 26 body('newCommentOnMyVideo')
27 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'), 27 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
28 body('videoAbuseAsModerator') 28 body('abuseAsModerator')
29 .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'), 29 .custom(isUserNotificationSettingValid).withMessage('Should have a valid abuse as moderator notification setting'),
30 body('videoAutoBlacklistAsModerator') 30 body('videoAutoBlacklistAsModerator')
31 .custom(isUserNotificationSettingValid).withMessage('Should have a valid video auto blacklist notification setting'), 31 .custom(isUserNotificationSettingValid).withMessage('Should have a valid video auto blacklist notification setting'),
32 body('blacklistOnMyVideo') 32 body('blacklistOnMyVideo')
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index a0d585b93..1eabada0a 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -1,4 +1,3 @@
1export * from './video-abuses'
2export * from './video-blacklist' 1export * from './video-blacklist'
3export * from './video-captions' 2export * from './video-captions'
4export * from './video-channels' 3export * from './video-channels'
diff --git a/server/middlewares/validators/videos/video-abuses.ts b/server/middlewares/validators/videos/video-abuses.ts
deleted file mode 100644
index 5bbd1e3c6..000000000
--- a/server/middlewares/validators/videos/video-abuses.ts
+++ /dev/null
@@ -1,135 +0,0 @@
1import * as express from 'express'
2import { body, param, query } from 'express-validator'
3import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
4import {
5 isAbuseVideoIsValid,
6 isVideoAbuseModerationCommentValid,
7 isVideoAbuseReasonValid,
8 isVideoAbuseStateValid,
9 isVideoAbusePredefinedReasonsValid,
10 isVideoAbusePredefinedReasonValid,
11 isVideoAbuseTimestampValid,
12 isVideoAbuseTimestampCoherent
13} from '../../../helpers/custom-validators/video-abuses'
14import { logger } from '../../../helpers/logger'
15import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares'
16import { areValidationErrors } from '../utils'
17
18const videoAbuseReportValidator = [
19 param('videoId')
20 .custom(isIdOrUUIDValid)
21 .not()
22 .isEmpty()
23 .withMessage('Should have a valid videoId'),
24 body('reason')
25 .custom(isVideoAbuseReasonValid)
26 .withMessage('Should have a valid reason'),
27 body('predefinedReasons')
28 .optional()
29 .custom(isVideoAbusePredefinedReasonsValid)
30 .withMessage('Should have a valid list of predefined reasons'),
31 body('startAt')
32 .optional()
33 .customSanitizer(toIntOrNull)
34 .custom(isVideoAbuseTimestampValid)
35 .withMessage('Should have valid starting time value'),
36 body('endAt')
37 .optional()
38 .customSanitizer(toIntOrNull)
39 .custom(isVideoAbuseTimestampValid)
40 .withMessage('Should have valid ending time value')
41 .bail()
42 .custom(isVideoAbuseTimestampCoherent)
43 .withMessage('Should have a startAt timestamp beginning before endAt'),
44
45 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
46 logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
47
48 if (areValidationErrors(req, res)) return
49 if (!await doesVideoExist(req.params.videoId, res)) return
50
51 return next()
52 }
53]
54
55const videoAbuseGetValidator = [
56 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
57 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
58
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
61
62 if (areValidationErrors(req, res)) return
63 if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
64
65 return next()
66 }
67]
68
69const videoAbuseUpdateValidator = [
70 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
71 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
72 body('state')
73 .optional()
74 .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
75 body('moderationComment')
76 .optional()
77 .custom(isVideoAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'),
78
79 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
80 logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
81
82 if (areValidationErrors(req, res)) return
83 if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
84
85 return next()
86 }
87]
88
89const videoAbuseListValidator = [
90 query('id')
91 .optional()
92 .custom(isIdValid).withMessage('Should have a valid id'),
93 query('predefinedReason')
94 .optional()
95 .custom(isVideoAbusePredefinedReasonValid)
96 .withMessage('Should have a valid predefinedReason'),
97 query('search')
98 .optional()
99 .custom(exists).withMessage('Should have a valid search'),
100 query('state')
101 .optional()
102 .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
103 query('videoIs')
104 .optional()
105 .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
106 query('searchReporter')
107 .optional()
108 .custom(exists).withMessage('Should have a valid reporter search'),
109 query('searchReportee')
110 .optional()
111 .custom(exists).withMessage('Should have a valid reportee search'),
112 query('searchVideo')
113 .optional()
114 .custom(exists).withMessage('Should have a valid video search'),
115 query('searchVideoChannel')
116 .optional()
117 .custom(exists).withMessage('Should have a valid video channel search'),
118
119 (req: express.Request, res: express.Response, next: express.NextFunction) => {
120 logger.debug('Checking videoAbuseListValidator parameters', { parameters: req.body })
121
122 if (areValidationErrors(req, res)) return
123
124 return next()
125 }
126]
127
128// ---------------------------------------------------------------------------
129
130export {
131 videoAbuseListValidator,
132 videoAbuseReportValidator,
133 videoAbuseGetValidator,
134 videoAbuseUpdateValidator
135}
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index ef019fcf9..77f5c6ff3 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -3,13 +3,16 @@ import { body, param } from 'express-validator'
3import { MUserAccountUrl } from '@server/types/models' 3import { MUserAccountUrl } from '@server/types/models'
4import { UserRight } from '../../../../shared' 4import { UserRight } from '../../../../shared'
5import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' 5import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
6import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments' 6import {
7 doesVideoCommentExist,
8 doesVideoCommentThreadExist,
9 isValidVideoCommentText
10} from '../../../helpers/custom-validators/video-comments'
7import { logger } from '../../../helpers/logger' 11import { logger } from '../../../helpers/logger'
8import { doesVideoExist } from '../../../helpers/middlewares' 12import { doesVideoExist } from '../../../helpers/middlewares'
9import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation' 13import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation'
10import { Hooks } from '../../../lib/plugins/hooks' 14import { Hooks } from '../../../lib/plugins/hooks'
11import { VideoCommentModel } from '../../../models/video/video-comment' 15import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video'
12import { MCommentOwnerVideoReply, MVideo, MVideoFullLight, MVideoId } from '../../../types/models/video'
13import { areValidationErrors } from '../utils' 16import { areValidationErrors } from '../utils'
14 17
15const listVideoCommentThreadsValidator = [ 18const listVideoCommentThreadsValidator = [
@@ -120,67 +123,10 @@ export {
120 123
121// --------------------------------------------------------------------------- 124// ---------------------------------------------------------------------------
122 125
123async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
124 const id = parseInt(idArg + '', 10)
125 const videoComment = await VideoCommentModel.loadById(id)
126
127 if (!videoComment) {
128 res.status(404)
129 .json({ error: 'Video comment thread not found' })
130 .end()
131
132 return false
133 }
134
135 if (videoComment.videoId !== video.id) {
136 res.status(400)
137 .json({ error: 'Video comment is not associated to this video.' })
138 .end()
139
140 return false
141 }
142
143 if (videoComment.inReplyToCommentId !== null) {
144 res.status(400)
145 .json({ error: 'Video comment is not a thread.' })
146 .end()
147
148 return false
149 }
150
151 res.locals.videoCommentThread = videoComment
152 return true
153}
154
155async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
156 const id = parseInt(idArg + '', 10)
157 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
158
159 if (!videoComment) {
160 res.status(404)
161 .json({ error: 'Video comment thread not found' })
162 .end()
163
164 return false
165 }
166
167 if (videoComment.videoId !== video.id) {
168 res.status(400)
169 .json({ error: 'Video comment is not associated to this video.' })
170 .end()
171
172 return false
173 }
174
175 res.locals.videoCommentFull = videoComment
176 return true
177}
178
179function isVideoCommentsEnabled (video: MVideo, res: express.Response) { 126function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
180 if (video.commentsEnabled !== true) { 127 if (video.commentsEnabled !== true) {
181 res.status(409) 128 res.status(409)
182 .json({ error: 'Video comments are disabled for this video.' }) 129 .json({ error: 'Video comments are disabled for this video.' })
183 .end()
184 130
185 return false 131 return false
186 } 132 }
@@ -192,7 +138,7 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC
192 if (videoComment.isDeleted()) { 138 if (videoComment.isDeleted()) {
193 res.status(409) 139 res.status(409)
194 .json({ error: 'This comment is already deleted' }) 140 .json({ error: 'This comment is already deleted' })
195 .end() 141
196 return false 142 return false
197 } 143 }
198 144
@@ -240,7 +186,7 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon
240 if (!acceptedResult || acceptedResult.accepted !== true) { 186 if (!acceptedResult || acceptedResult.accepted !== true) {
241 logger.info('Refused local comment.', { acceptedResult, acceptParameters }) 187 logger.info('Refused local comment.', { acceptedResult, acceptParameters })
242 res.status(403) 188 res.status(403)
243 .json({ error: acceptedResult.errorMessage || 'Refused local comment' }) 189 .json({ error: acceptedResult.errorMessage || 'Refused local comment' })
244 190
245 return false 191 return false
246 } 192 }
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/abuse-query-builder.ts
new file mode 100644
index 000000000..5fddcf3c4
--- /dev/null
+++ b/server/models/abuse/abuse-query-builder.ts
@@ -0,0 +1,154 @@
1
2import { exists } from '@server/helpers/custom-validators/misc'
3import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
4import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils'
5
6export type BuildAbusesQueryOptions = {
7 start: number
8 count: number
9 sort: string
10
11 // search
12 search?: string
13 searchReporter?: string
14 searchReportee?: string
15
16 // video releated
17 searchVideo?: string
18 searchVideoChannel?: string
19 videoIs?: AbuseVideoIs
20
21 // filters
22 id?: number
23 predefinedReasonId?: number
24 filter?: AbuseFilter
25
26 state?: AbuseState
27
28 // accountIds
29 serverAccountId: number
30 userAccountId: number
31}
32
33function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' | 'id') {
34 const whereAnd: string[] = []
35 const replacements: any = {}
36
37 const joins = [
38 'LEFT JOIN "videoAbuse" ON "videoAbuse"."abuseId" = "abuse"."id"',
39 'LEFT JOIN "video" ON "videoAbuse"."videoId" = "video"."id"',
40 'LEFT JOIN "videoBlacklist" ON "videoBlacklist"."videoId" = "video"."id"',
41 'LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"',
42 'LEFT JOIN "account" "reporterAccount" ON "reporterAccount"."id" = "abuse"."reporterAccountId"',
43 'LEFT JOIN "account" "flaggedAccount" ON "flaggedAccount"."id" = "abuse"."reporterAccountId"',
44 'LEFT JOIN "commentAbuse" ON "commentAbuse"."abuseId" = "abuse"."id"',
45 'LEFT JOIN "videoComment" ON "commentAbuse"."videoCommentId" = "videoComment"."id"'
46 ]
47
48 whereAnd.push('"abuse"."reporterAccountId" NOT IN (' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
49
50 if (options.search) {
51 const searchWhereOr = [
52 '"video"."name" ILIKE :search',
53 '"videoChannel"."name" ILIKE :search',
54 `"videoAbuse"."deletedVideo"->>'name' ILIKE :search`,
55 `"videoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE :search`,
56 '"reporterAccount"."name" ILIKE :search',
57 '"flaggedAccount"."name" ILIKE :search'
58 ]
59
60 replacements.search = `%${options.search}%`
61 whereAnd.push('(' + searchWhereOr.join(' OR ') + ')')
62 }
63
64 if (options.searchVideo) {
65 whereAnd.push('"video"."name" ILIKE :searchVideo')
66 replacements.searchVideo = `%${options.searchVideo}%`
67 }
68
69 if (options.searchVideoChannel) {
70 whereAnd.push('"videoChannel"."name" ILIKE :searchVideoChannel')
71 replacements.searchVideoChannel = `%${options.searchVideoChannel}%`
72 }
73
74 if (options.id) {
75 whereAnd.push('"abuse"."id" = :id')
76 replacements.id = options.id
77 }
78
79 if (options.state) {
80 whereAnd.push('"abuse"."state" = :state')
81 replacements.state = options.state
82 }
83
84 if (options.videoIs === 'deleted') {
85 whereAnd.push('"videoAbuse"."deletedVideo" IS NOT NULL')
86 } else if (options.videoIs === 'blacklisted') {
87 whereAnd.push('"videoBlacklist"."id" IS NOT NULL')
88 }
89
90 if (options.predefinedReasonId) {
91 whereAnd.push(':predefinedReasonId = ANY("abuse"."predefinedReasons")')
92 replacements.predefinedReasonId = options.predefinedReasonId
93 }
94
95 if (options.filter === 'video') {
96 whereAnd.push('"videoAbuse"."id" IS NOT NULL')
97 } else if (options.filter === 'comment') {
98 whereAnd.push('"commentAbuse"."id" IS NOT NULL')
99 } else if (options.filter === 'account') {
100 whereAnd.push('"videoAbuse"."id" IS NULL AND "commentAbuse"."id" IS NULL')
101 }
102
103 if (options.searchReporter) {
104 whereAnd.push('"reporterAccount"."name" ILIKE :searchReporter')
105 replacements.searchReporter = `%${options.searchReporter}%`
106 }
107
108 if (options.searchReportee) {
109 whereAnd.push('"flaggedAccount"."name" ILIKE :searchReportee')
110 replacements.searchReportee = `%${options.searchReportee}%`
111 }
112
113 const prefix = type === 'count'
114 ? 'SELECT COUNT("abuse"."id") AS "total"'
115 : 'SELECT "abuse"."id" '
116
117 let suffix = ''
118 if (type !== 'count') {
119
120 if (options.sort) {
121 const order = buildAbuseOrder(options.sort)
122 suffix += `${order} `
123 }
124
125 if (exists(options.count)) {
126 const count = parseInt(options.count + '', 10)
127 suffix += `LIMIT ${count} `
128 }
129
130 if (exists(options.start)) {
131 const start = parseInt(options.start + '', 10)
132 suffix += `OFFSET ${start} `
133 }
134 }
135
136 const where = whereAnd.length !== 0
137 ? `WHERE ${whereAnd.join(' AND ')}`
138 : ''
139
140 return {
141 query: `${prefix} FROM "abuse" ${joins.join(' ')} ${where} ${suffix}`,
142 replacements
143 }
144}
145
146function buildAbuseOrder (value: string) {
147 const { direction, field } = buildDirectionAndField(value)
148
149 return `ORDER BY "abuse"."${field}" ${direction}`
150}
151
152export {
153 buildAbuseListQuery
154}
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
new file mode 100644
index 000000000..bd96cf79c
--- /dev/null
+++ b/server/models/abuse/abuse.ts
@@ -0,0 +1,515 @@
1import * as Bluebird from 'bluebird'
2import { invert } from 'lodash'
3import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'
4import {
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 Default,
11 ForeignKey,
12 HasOne,
13 Is,
14 Model,
15 Scopes,
16 Table,
17 UpdatedAt
18} from 'sequelize-typescript'
19import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
20import {
21 Abuse,
22 AbuseFilter,
23 AbuseObject,
24 AbusePredefinedReasons,
25 abusePredefinedReasonsMap,
26 AbusePredefinedReasonsString,
27 AbuseState,
28 AbuseVideoIs,
29 VideoAbuse,
30 VideoCommentAbuse
31} from '@shared/models'
32import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models'
34import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
35import { getSort, throwIfNotValid } from '../utils'
36import { ThumbnailModel } from '../video/thumbnail'
37import { VideoModel } from '../video/video'
38import { VideoBlacklistModel } from '../video/video-blacklist'
39import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
40import { VideoCommentModel } from '../video/video-comment'
41import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
42import { VideoAbuseModel } from './video-abuse'
43import { VideoCommentAbuseModel } from './video-comment-abuse'
44
45export enum ScopeNames {
46 FOR_API = 'FOR_API'
47}
48
49@Scopes(() => ({
50 [ScopeNames.FOR_API]: () => {
51 return {
52 attributes: {
53 include: [
54 [
55 // we don't care about this count for deleted videos, so there are not included
56 literal(
57 '(' +
58 'SELECT count(*) ' +
59 'FROM "videoAbuse" ' +
60 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
61 ')'
62 ),
63 'countReportsForVideo'
64 ],
65 [
66 // we don't care about this count for deleted videos, so there are not included
67 literal(
68 '(' +
69 'SELECT t.nth ' +
70 'FROM ( ' +
71 'SELECT id, ' +
72 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
73 'FROM "videoAbuse" ' +
74 ') t ' +
75 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
76 ')'
77 ),
78 'nthReportForVideo'
79 ],
80 [
81 literal(
82 '(' +
83 'SELECT count("abuse"."id") ' +
84 'FROM "abuse" ' +
85 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
86 ')'
87 ),
88 'countReportsForReporter'
89 ],
90 [
91 literal(
92 '(' +
93 'SELECT count("abuse"."id") ' +
94 'FROM "abuse" ' +
95 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
96 ')'
97 ),
98 'countReportsForReportee'
99 ]
100 ]
101 },
102 include: [
103 {
104 model: AccountModel.scope({
105 method: [
106 AccountScopeNames.SUMMARY,
107 { actorRequired: false } as AccountSummaryOptions
108 ]
109 }),
110 as: 'ReporterAccount'
111 },
112 {
113 model: AccountModel.scope({
114 method: [
115 AccountScopeNames.SUMMARY,
116 { actorRequired: false } as AccountSummaryOptions
117 ]
118 }),
119 as: 'FlaggedAccount'
120 },
121 {
122 model: VideoCommentAbuseModel.unscoped(),
123 include: [
124 {
125 model: VideoCommentModel.unscoped(),
126 include: [
127 {
128 model: VideoModel.unscoped(),
129 attributes: [ 'name', 'id', 'uuid' ]
130 }
131 ]
132 }
133 ]
134 },
135 {
136 model: VideoAbuseModel.unscoped(),
137 include: [
138 {
139 attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
140 model: VideoModel.unscoped(),
141 include: [
142 {
143 attributes: [ 'filename', 'fileUrl', 'type' ],
144 model: ThumbnailModel
145 },
146 {
147 model: VideoChannelModel.scope({
148 method: [
149 VideoChannelScopeNames.SUMMARY,
150 { withAccount: false, actorRequired: false } as ChannelSummaryOptions
151 ]
152 }),
153 required: false
154 },
155 {
156 attributes: [ 'id', 'reason', 'unfederated' ],
157 required: false,
158 model: VideoBlacklistModel
159 }
160 ]
161 }
162 ]
163 }
164 ]
165 }
166 }
167}))
168@Table({
169 tableName: 'abuse',
170 indexes: [
171 {
172 fields: [ 'reporterAccountId' ]
173 },
174 {
175 fields: [ 'flaggedAccountId' ]
176 }
177 ]
178})
179export class AbuseModel extends Model<AbuseModel> {
180
181 @AllowNull(false)
182 @Default(null)
183 @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
184 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
185 reason: string
186
187 @AllowNull(false)
188 @Default(null)
189 @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
190 @Column
191 state: AbuseState
192
193 @AllowNull(true)
194 @Default(null)
195 @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
196 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
197 moderationComment: string
198
199 @AllowNull(true)
200 @Default(null)
201 @Column(DataType.ARRAY(DataType.INTEGER))
202 predefinedReasons: AbusePredefinedReasons[]
203
204 @CreatedAt
205 createdAt: Date
206
207 @UpdatedAt
208 updatedAt: Date
209
210 @ForeignKey(() => AccountModel)
211 @Column
212 reporterAccountId: number
213
214 @BelongsTo(() => AccountModel, {
215 foreignKey: {
216 name: 'reporterAccountId',
217 allowNull: true
218 },
219 as: 'ReporterAccount',
220 onDelete: 'set null'
221 })
222 ReporterAccount: AccountModel
223
224 @ForeignKey(() => AccountModel)
225 @Column
226 flaggedAccountId: number
227
228 @BelongsTo(() => AccountModel, {
229 foreignKey: {
230 name: 'flaggedAccountId',
231 allowNull: true
232 },
233 as: 'FlaggedAccount',
234 onDelete: 'set null'
235 })
236 FlaggedAccount: AccountModel
237
238 @HasOne(() => VideoCommentAbuseModel, {
239 foreignKey: {
240 name: 'abuseId',
241 allowNull: false
242 },
243 onDelete: 'cascade'
244 })
245 VideoCommentAbuse: VideoCommentAbuseModel
246
247 @HasOne(() => VideoAbuseModel, {
248 foreignKey: {
249 name: 'abuseId',
250 allowNull: false
251 },
252 onDelete: 'cascade'
253 })
254 VideoAbuse: VideoAbuseModel
255
256 // FIXME: deprecated in 2.3. Remove these validators
257 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
258 const videoWhere: WhereOptions = {}
259
260 if (videoId) videoWhere.videoId = videoId
261 if (uuid) videoWhere.deletedVideo = { uuid }
262
263 const query = {
264 include: [
265 {
266 model: VideoAbuseModel,
267 required: true,
268 where: videoWhere
269 }
270 ],
271 where: {
272 id
273 }
274 }
275 return AbuseModel.findOne(query)
276 }
277
278 static loadById (id: number): Bluebird<MAbuse> {
279 const query = {
280 where: {
281 id
282 }
283 }
284
285 return AbuseModel.findOne(query)
286 }
287
288 static async listForApi (parameters: {
289 start: number
290 count: number
291 sort: string
292
293 filter?: AbuseFilter
294
295 serverAccountId: number
296 user?: MUserAccountId
297
298 id?: number
299 predefinedReason?: AbusePredefinedReasonsString
300 state?: AbuseState
301 videoIs?: AbuseVideoIs
302
303 search?: string
304 searchReporter?: string
305 searchReportee?: string
306 searchVideo?: string
307 searchVideoChannel?: string
308 }) {
309 const {
310 start,
311 count,
312 sort,
313 search,
314 user,
315 serverAccountId,
316 state,
317 videoIs,
318 predefinedReason,
319 searchReportee,
320 searchVideo,
321 filter,
322 searchVideoChannel,
323 searchReporter,
324 id
325 } = parameters
326
327 const userAccountId = user ? user.Account.id : undefined
328 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
329
330 const queryOptions: BuildAbusesQueryOptions = {
331 start,
332 count,
333 sort,
334 id,
335 filter,
336 predefinedReasonId,
337 search,
338 state,
339 videoIs,
340 searchReportee,
341 searchVideo,
342 searchVideoChannel,
343 searchReporter,
344 serverAccountId,
345 userAccountId
346 }
347
348 const [ total, data ] = await Promise.all([
349 AbuseModel.internalCountForApi(queryOptions),
350 AbuseModel.internalListForApi(queryOptions)
351 ])
352
353 return { total, data }
354 }
355
356 toFormattedJSON (this: MAbuseFormattable): Abuse {
357 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
358
359 const countReportsForVideo = this.get('countReportsForVideo') as number
360 const nthReportForVideo = this.get('nthReportForVideo') as number
361
362 const countReportsForReporter = this.get('countReportsForReporter') as number
363 const countReportsForReportee = this.get('countReportsForReportee') as number
364
365 let video: VideoAbuse = null
366 let comment: VideoCommentAbuse = null
367
368 if (this.VideoAbuse) {
369 const abuseModel = this.VideoAbuse
370 const entity = abuseModel.Video || abuseModel.deletedVideo
371
372 video = {
373 id: entity.id,
374 uuid: entity.uuid,
375 name: entity.name,
376 nsfw: entity.nsfw,
377
378 startAt: abuseModel.startAt,
379 endAt: abuseModel.endAt,
380
381 deleted: !abuseModel.Video,
382 blacklisted: abuseModel.Video?.isBlacklisted() || false,
383 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
384
385 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel,
386
387 countReports: countReportsForVideo,
388 nthReport: nthReportForVideo
389 }
390 }
391
392 if (this.VideoCommentAbuse) {
393 const abuseModel = this.VideoCommentAbuse
394 const entity = abuseModel.VideoComment
395
396 comment = {
397 id: entity.id,
398 threadId: entity.getThreadId(),
399
400 text: entity.text ?? '',
401
402 deleted: entity.isDeleted(),
403
404 video: {
405 id: entity.Video.id,
406 name: entity.Video.name,
407 uuid: entity.Video.uuid
408 }
409 }
410 }
411
412 return {
413 id: this.id,
414 reason: this.reason,
415 predefinedReasons,
416
417 reporterAccount: this.ReporterAccount
418 ? this.ReporterAccount.toFormattedJSON()
419 : null,
420
421 flaggedAccount: this.FlaggedAccount
422 ? this.FlaggedAccount.toFormattedJSON()
423 : null,
424
425 state: {
426 id: this.state,
427 label: AbuseModel.getStateLabel(this.state)
428 },
429
430 moderationComment: this.moderationComment,
431
432 video,
433 comment,
434
435 createdAt: this.createdAt,
436 updatedAt: this.updatedAt,
437
438 countReportsForReporter: (countReportsForReporter || 0),
439 countReportsForReportee: (countReportsForReportee || 0),
440
441 // FIXME: deprecated in 2.3, remove this
442 startAt: null,
443 endAt: null,
444 count: countReportsForVideo || 0,
445 nth: nthReportForVideo || 0
446 }
447 }
448
449 toActivityPubObject (this: MAbuseAP): AbuseObject {
450 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
451
452 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
453
454 const startAt = this.VideoAbuse?.startAt
455 const endAt = this.VideoAbuse?.endAt
456
457 return {
458 type: 'Flag' as 'Flag',
459 content: this.reason,
460 object,
461 tag: predefinedReasons.map(r => ({
462 type: 'Hashtag' as 'Hashtag',
463 name: r
464 })),
465 startAt,
466 endAt
467 }
468 }
469
470 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
471 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
472 const options = {
473 type: QueryTypes.SELECT as QueryTypes.SELECT,
474 replacements
475 }
476
477 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
478 if (total === null) return 0
479
480 return parseInt(total, 10)
481 }
482
483 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
484 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
485 const options = {
486 type: QueryTypes.SELECT as QueryTypes.SELECT,
487 replacements
488 }
489
490 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
491 const ids = rows.map(r => r.id)
492
493 if (ids.length === 0) return []
494
495 return AbuseModel.scope(ScopeNames.FOR_API)
496 .findAll({
497 order: getSort(parameters.sort),
498 where: {
499 id: {
500 [Op.in]: ids
501 }
502 }
503 })
504 }
505
506 private static getStateLabel (id: number) {
507 return ABUSE_STATES[id] || 'Unknown'
508 }
509
510 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
511 return (predefinedReasons || [])
512 .filter(r => r in AbusePredefinedReasons)
513 .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString)
514 }
515}
diff --git a/server/models/abuse/video-abuse.ts b/server/models/abuse/video-abuse.ts
new file mode 100644
index 000000000..d92bcf19f
--- /dev/null
+++ b/server/models/abuse/video-abuse.ts
@@ -0,0 +1,63 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoDetails } from '@shared/models'
3import { VideoModel } from '../video/video'
4import { AbuseModel } from './abuse'
5
6@Table({
7 tableName: 'videoAbuse',
8 indexes: [
9 {
10 fields: [ 'abuseId' ]
11 },
12 {
13 fields: [ 'videoId' ]
14 }
15 ]
16})
17export class VideoAbuseModel extends Model<VideoAbuseModel> {
18
19 @CreatedAt
20 createdAt: Date
21
22 @UpdatedAt
23 updatedAt: Date
24
25 @AllowNull(true)
26 @Default(null)
27 @Column
28 startAt: number
29
30 @AllowNull(true)
31 @Default(null)
32 @Column
33 endAt: number
34
35 @AllowNull(true)
36 @Default(null)
37 @Column(DataType.JSONB)
38 deletedVideo: VideoDetails
39
40 @ForeignKey(() => AbuseModel)
41 @Column
42 abuseId: number
43
44 @BelongsTo(() => AbuseModel, {
45 foreignKey: {
46 allowNull: false
47 },
48 onDelete: 'cascade'
49 })
50 Abuse: AbuseModel
51
52 @ForeignKey(() => VideoModel)
53 @Column
54 videoId: number
55
56 @BelongsTo(() => VideoModel, {
57 foreignKey: {
58 allowNull: true
59 },
60 onDelete: 'set null'
61 })
62 Video: VideoModel
63}
diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts
new file mode 100644
index 000000000..8b34009b4
--- /dev/null
+++ b/server/models/abuse/video-comment-abuse.ts
@@ -0,0 +1,47 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoCommentModel } from '../video/video-comment'
3import { AbuseModel } from './abuse'
4
5@Table({
6 tableName: 'commentAbuse',
7 indexes: [
8 {
9 fields: [ 'abuseId' ]
10 },
11 {
12 fields: [ 'videoCommentId' ]
13 }
14 ]
15})
16export class VideoCommentAbuseModel extends Model<VideoCommentAbuseModel> {
17
18 @CreatedAt
19 createdAt: Date
20
21 @UpdatedAt
22 updatedAt: Date
23
24 @ForeignKey(() => AbuseModel)
25 @Column
26 abuseId: number
27
28 @BelongsTo(() => AbuseModel, {
29 foreignKey: {
30 allowNull: false
31 },
32 onDelete: 'cascade'
33 })
34 Abuse: AbuseModel
35
36 @ForeignKey(() => VideoCommentModel)
37 @Column
38 videoCommentId: number
39
40 @BelongsTo(() => VideoCommentModel, {
41 foreignKey: {
42 allowNull: true
43 },
44 onDelete: 'set null'
45 })
46 VideoComment: VideoCommentModel
47}
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index cf8872fd5..577b7dc19 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -1,12 +1,12 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from './account'
3import { getSort, searchAttribute } from '../utils'
4import { AccountBlock } from '../../../shared/models/blocklist'
5import { Op } from 'sequelize'
6import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { Op } from 'sequelize'
3import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
7import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' 4import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
5import { AccountBlock } from '../../../shared/models'
8import { ActorModel } from '../activitypub/actor' 6import { ActorModel } from '../activitypub/actor'
9import { ServerModel } from '../server/server' 7import { ServerModel } from '../server/server'
8import { getSort, searchAttribute } from '../utils'
9import { AccountModel } from './account'
10 10
11enum ScopeNames { 11enum ScopeNames {
12 WITH_ACCOUNTS = 'WITH_ACCOUNTS' 12 WITH_ACCOUNTS = 'WITH_ACCOUNTS'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 4395d179a..f97519b14 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -42,6 +42,7 @@ export enum ScopeNames {
42} 42}
43 43
44export type SummaryOptions = { 44export type SummaryOptions = {
45 actorRequired?: boolean // Default: true
45 whereActor?: WhereOptions 46 whereActor?: WhereOptions
46 withAccountBlockerIds?: number[] 47 withAccountBlockerIds?: number[]
47} 48}
@@ -65,12 +66,12 @@ export type SummaryOptions = {
65 } 66 }
66 67
67 const query: FindOptions = { 68 const query: FindOptions = {
68 attributes: [ 'id', 'name' ], 69 attributes: [ 'id', 'name', 'actorId' ],
69 include: [ 70 include: [
70 { 71 {
71 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 72 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
72 model: ActorModel.unscoped(), 73 model: ActorModel.unscoped(),
73 required: true, 74 required: options.actorRequired ?? true,
74 where: whereActor, 75 where: whereActor,
75 include: [ 76 include: [
76 serverInclude, 77 serverInclude,
@@ -388,6 +389,10 @@ export class AccountModel extends Model<AccountModel> {
388 .findAll(query) 389 .findAll(query)
389 } 390 }
390 391
392 getClientUrl () {
393 return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
394 }
395
391 toFormattedJSON (this: MAccountFormattable): Account { 396 toFormattedJSON (this: MAccountFormattable): Account {
392 const actor = this.Actor.toFormattedJSON() 397 const actor = this.Actor.toFormattedJSON()
393 const account = { 398 const account = {
diff --git a/server/models/account/user-notification-setting.ts b/server/models/account/user-notification-setting.ts
index b69b47265..d8f3f13da 100644
--- a/server/models/account/user-notification-setting.ts
+++ b/server/models/account/user-notification-setting.ts
@@ -51,11 +51,11 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
51 @AllowNull(false) 51 @AllowNull(false)
52 @Default(null) 52 @Default(null)
53 @Is( 53 @Is(
54 'UserNotificationSettingVideoAbuseAsModerator', 54 'UserNotificationSettingAbuseAsModerator',
55 value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator') 55 value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseAsModerator')
56 ) 56 )
57 @Column 57 @Column
58 videoAbuseAsModerator: UserNotificationSettingValue 58 abuseAsModerator: UserNotificationSettingValue
59 59
60 @AllowNull(false) 60 @AllowNull(false)
61 @Default(null) 61 @Default(null)
@@ -166,7 +166,7 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
166 return { 166 return {
167 newCommentOnMyVideo: this.newCommentOnMyVideo, 167 newCommentOnMyVideo: this.newCommentOnMyVideo,
168 newVideoFromSubscription: this.newVideoFromSubscription, 168 newVideoFromSubscription: this.newVideoFromSubscription,
169 videoAbuseAsModerator: this.videoAbuseAsModerator, 169 abuseAsModerator: this.abuseAsModerator,
170 videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator, 170 videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
171 blacklistOnMyVideo: this.blacklistOnMyVideo, 171 blacklistOnMyVideo: this.blacklistOnMyVideo,
172 myVideoPublished: this.myVideoPublished, 172 myVideoPublished: this.myVideoPublished,
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 30985bb0f..2945bf709 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -1,22 +1,24 @@
1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
2import { UserNotification, UserNotificationType } from '../../../shared' 4import { UserNotification, UserNotificationType } from '../../../shared'
3import { getSort, throwIfNotValid } from '../utils'
4import { isBooleanValid } from '../../helpers/custom-validators/misc' 5import { isBooleanValid } from '../../helpers/custom-validators/misc'
5import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' 6import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
6import { UserModel } from './user' 7import { AbuseModel } from '../abuse/abuse'
7import { VideoModel } from '../video/video' 8import { VideoAbuseModel } from '../abuse/video-abuse'
8import { VideoCommentModel } from '../video/video-comment' 9import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
9import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
10import { VideoChannelModel } from '../video/video-channel'
11import { AccountModel } from './account'
12import { VideoAbuseModel } from '../video/video-abuse'
13import { VideoBlacklistModel } from '../video/video-blacklist'
14import { VideoImportModel } from '../video/video-import'
15import { ActorModel } from '../activitypub/actor' 10import { ActorModel } from '../activitypub/actor'
16import { ActorFollowModel } from '../activitypub/actor-follow' 11import { ActorFollowModel } from '../activitypub/actor-follow'
17import { AvatarModel } from '../avatar/avatar' 12import { AvatarModel } from '../avatar/avatar'
18import { ServerModel } from '../server/server' 13import { ServerModel } from '../server/server'
19import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' 14import { getSort, throwIfNotValid } from '../utils'
15import { VideoModel } from '../video/video'
16import { VideoBlacklistModel } from '../video/video-blacklist'
17import { VideoChannelModel } from '../video/video-channel'
18import { VideoCommentModel } from '../video/video-comment'
19import { VideoImportModel } from '../video/video-import'
20import { AccountModel } from './account'
21import { UserModel } from './user'
20 22
21enum ScopeNames { 23enum ScopeNames {
22 WITH_ALL = 'WITH_ALL' 24 WITH_ALL = 'WITH_ALL'
@@ -87,9 +89,41 @@ function buildAccountInclude (required: boolean, withActor = false) {
87 89
88 { 90 {
89 attributes: [ 'id' ], 91 attributes: [ 'id' ],
90 model: VideoAbuseModel.unscoped(), 92 model: AbuseModel.unscoped(),
91 required: false, 93 required: false,
92 include: [ buildVideoInclude(true) ] 94 include: [
95 {
96 attributes: [ 'id' ],
97 model: VideoAbuseModel.unscoped(),
98 required: false,
99 include: [ buildVideoInclude(true) ]
100 },
101 {
102 attributes: [ 'id' ],
103 model: VideoCommentAbuseModel.unscoped(),
104 required: false,
105 include: [
106 {
107 attributes: [ 'id', 'originCommentId' ],
108 model: VideoCommentModel,
109 required: true,
110 include: [
111 {
112 attributes: [ 'id', 'name', 'uuid' ],
113 model: VideoModel.unscoped(),
114 required: true
115 }
116 ]
117 }
118 ]
119 },
120 {
121 model: AccountModel,
122 as: 'FlaggedAccount',
123 required: true,
124 include: [ buildActorWithAvatarInclude() ]
125 }
126 ]
93 }, 127 },
94 128
95 { 129 {
@@ -179,9 +213,9 @@ function buildAccountInclude (required: boolean, withActor = false) {
179 } 213 }
180 }, 214 },
181 { 215 {
182 fields: [ 'videoAbuseId' ], 216 fields: [ 'abuseId' ],
183 where: { 217 where: {
184 videoAbuseId: { 218 abuseId: {
185 [Op.ne]: null 219 [Op.ne]: null
186 } 220 }
187 } 221 }
@@ -276,17 +310,17 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
276 }) 310 })
277 Comment: VideoCommentModel 311 Comment: VideoCommentModel
278 312
279 @ForeignKey(() => VideoAbuseModel) 313 @ForeignKey(() => AbuseModel)
280 @Column 314 @Column
281 videoAbuseId: number 315 abuseId: number
282 316
283 @BelongsTo(() => VideoAbuseModel, { 317 @BelongsTo(() => AbuseModel, {
284 foreignKey: { 318 foreignKey: {
285 allowNull: true 319 allowNull: true
286 }, 320 },
287 onDelete: 'cascade' 321 onDelete: 'cascade'
288 }) 322 })
289 VideoAbuse: VideoAbuseModel 323 Abuse: AbuseModel
290 324
291 @ForeignKey(() => VideoBlacklistModel) 325 @ForeignKey(() => VideoBlacklistModel)
292 @Column 326 @Column
@@ -397,10 +431,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
397 video: this.formatVideo(this.Comment.Video) 431 video: this.formatVideo(this.Comment.Video)
398 } : undefined 432 } : undefined
399 433
400 const videoAbuse = this.VideoAbuse ? { 434 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
401 id: this.VideoAbuse.id,
402 video: this.formatVideo(this.VideoAbuse.Video)
403 } : undefined
404 435
405 const videoBlacklist = this.VideoBlacklist ? { 436 const videoBlacklist = this.VideoBlacklist ? {
406 id: this.VideoBlacklist.id, 437 id: this.VideoBlacklist.id,
@@ -439,7 +470,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
439 video, 470 video,
440 videoImport, 471 videoImport,
441 comment, 472 comment,
442 videoAbuse, 473 abuse,
443 videoBlacklist, 474 videoBlacklist,
444 account, 475 account,
445 actorFollow, 476 actorFollow,
@@ -456,6 +487,29 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
456 } 487 }
457 } 488 }
458 489
490 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
491 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment ? {
492 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
493
494 video: {
495 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
496 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
497 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
498 }
499 } : undefined
500
501 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
502
503 const accountAbuse = (!commentAbuse && !videoAbuse) ? this.formatActor(abuse.FlaggedAccount) : undefined
504
505 return {
506 id: abuse.id,
507 video: videoAbuse,
508 comment: commentAbuse,
509 account: accountAbuse
510 }
511 }
512
459 formatActor ( 513 formatActor (
460 this: UserNotificationModelForApi, 514 this: UserNotificationModelForApi,
461 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor 515 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index de193131a..5f45f8e7c 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -19,7 +19,7 @@ import {
19 Table, 19 Table,
20 UpdatedAt 20 UpdatedAt
21} from 'sequelize-typescript' 21} from 'sequelize-typescript'
22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoAbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared' 22import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, AbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared'
23import { User, UserRole } from '../../../shared/models/users' 23import { User, UserRole } from '../../../shared/models/users'
24import { 24import {
25 isNoInstanceConfigWarningModal, 25 isNoInstanceConfigWarningModal,
@@ -168,28 +168,26 @@ enum ScopeNames {
168 '(' + 168 '(' +
169 `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` + 169 `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
170 'FROM (' + 170 'FROM (' +
171 'SELECT COUNT("videoAbuse"."id") AS "abuses", ' + 171 'SELECT COUNT("abuse"."id") AS "abuses", ' +
172 `COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` + 172 `COUNT("abuse"."id") FILTER (WHERE "abuse"."state" = ${AbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
173 'FROM "videoAbuse" ' + 173 'FROM "abuse" ' +
174 'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' + 174 'INNER JOIN "account" ON "account"."id" = "abuse"."flaggedAccountId" ' +
175 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
176 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
177 'WHERE "account"."userId" = "UserModel"."id"' + 175 'WHERE "account"."userId" = "UserModel"."id"' +
178 ') t' + 176 ') t' +
179 ')' 177 ')'
180 ), 178 ),
181 'videoAbusesCount' 179 'abusesCount'
182 ], 180 ],
183 [ 181 [
184 literal( 182 literal(
185 '(' + 183 '(' +
186 'SELECT COUNT("videoAbuse"."id") ' + 184 'SELECT COUNT("abuse"."id") ' +
187 'FROM "videoAbuse" ' + 185 'FROM "abuse" ' +
188 'INNER JOIN "account" ON "account"."id" = "videoAbuse"."reporterAccountId" ' + 186 'INNER JOIN "account" ON "account"."id" = "abuse"."reporterAccountId" ' +
189 'WHERE "account"."userId" = "UserModel"."id"' + 187 'WHERE "account"."userId" = "UserModel"."id"' +
190 ')' 188 ')'
191 ), 189 ),
192 'videoAbusesCreatedCount' 190 'abusesCreatedCount'
193 ], 191 ],
194 [ 192 [
195 literal( 193 literal(
@@ -780,8 +778,8 @@ export class UserModel extends Model<UserModel> {
780 const videoQuotaUsed = this.get('videoQuotaUsed') 778 const videoQuotaUsed = this.get('videoQuotaUsed')
781 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily') 779 const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
782 const videosCount = this.get('videosCount') 780 const videosCount = this.get('videosCount')
783 const [ videoAbusesCount, videoAbusesAcceptedCount ] = (this.get('videoAbusesCount') as string || ':').split(':') 781 const [ abusesCount, abusesAcceptedCount ] = (this.get('abusesCount') as string || ':').split(':')
784 const videoAbusesCreatedCount = this.get('videoAbusesCreatedCount') 782 const abusesCreatedCount = this.get('abusesCreatedCount')
785 const videoCommentsCount = this.get('videoCommentsCount') 783 const videoCommentsCount = this.get('videoCommentsCount')
786 784
787 const json: User = { 785 const json: User = {
@@ -815,14 +813,14 @@ export class UserModel extends Model<UserModel> {
815 videosCount: videosCount !== undefined 813 videosCount: videosCount !== undefined
816 ? parseInt(videosCount + '', 10) 814 ? parseInt(videosCount + '', 10)
817 : undefined, 815 : undefined,
818 videoAbusesCount: videoAbusesCount 816 abusesCount: abusesCount
819 ? parseInt(videoAbusesCount, 10) 817 ? parseInt(abusesCount, 10)
820 : undefined, 818 : undefined,
821 videoAbusesAcceptedCount: videoAbusesAcceptedCount 819 abusesAcceptedCount: abusesAcceptedCount
822 ? parseInt(videoAbusesAcceptedCount, 10) 820 ? parseInt(abusesAcceptedCount, 10)
823 : undefined, 821 : undefined,
824 videoAbusesCreatedCount: videoAbusesCreatedCount !== undefined 822 abusesCreatedCount: abusesCreatedCount !== undefined
825 ? parseInt(videoAbusesCreatedCount + '', 10) 823 ? parseInt(abusesCreatedCount + '', 10)
826 : undefined, 824 : undefined,
827 videoCommentsCount: videoCommentsCount !== undefined 825 videoCommentsCount: videoCommentsCount !== undefined
828 ? parseInt(videoCommentsCount + '', 10) 826 ? parseInt(videoCommentsCount + '', 10)
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 30f0525e5..68cd72ee7 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -1,11 +1,11 @@
1import * as Bluebird from 'bluebird'
2import { Op } from 'sequelize'
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 3import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
4import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
5import { ServerBlock } from '@shared/models'
2import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
3import { ServerModel } from './server'
4import { ServerBlock } from '../../../shared/models/blocklist'
5import { getSort, searchAttribute } from '../utils' 7import { getSort, searchAttribute } from '../utils'
6import * as Bluebird from 'bluebird' 8import { ServerModel } from './server'
7import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
8import { Op } from 'sequelize'
9 9
10enum ScopeNames { 10enum ScopeNames {
11 WITH_ACCOUNT = 'WITH_ACCOUNT', 11 WITH_ACCOUNT = 'WITH_ACCOUNT',
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
deleted file mode 100644
index 1319332f0..000000000
--- a/server/models/video/video-abuse.ts
+++ /dev/null
@@ -1,479 +0,0 @@
1import * as Bluebird from 'bluebird'
2import { literal, Op } from 'sequelize'
3import {
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 ForeignKey,
11 Is,
12 Model,
13 Scopes,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
18import {
19 VideoAbuseState,
20 VideoDetails,
21 VideoAbusePredefinedReasons,
22 VideoAbusePredefinedReasonsString,
23 videoAbusePredefinedReasonsMap
24} from '../../../shared'
25import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
26import { VideoAbuse } from '../../../shared/models/videos'
27import {
28 isVideoAbuseModerationCommentValid,
29 isVideoAbuseReasonValid,
30 isVideoAbuseStateValid
31} from '../../helpers/custom-validators/video-abuses'
32import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
33import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../types/models'
34import { AccountModel } from '../account/account'
35import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
36import { ThumbnailModel } from './thumbnail'
37import { VideoModel } from './video'
38import { VideoBlacklistModel } from './video-blacklist'
39import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
40import { invert } from 'lodash'
41
42export enum ScopeNames {
43 FOR_API = 'FOR_API'
44}
45
46@Scopes(() => ({
47 [ScopeNames.FOR_API]: (options: {
48 // search
49 search?: string
50 searchReporter?: string
51 searchReportee?: string
52 searchVideo?: string
53 searchVideoChannel?: string
54
55 // filters
56 id?: number
57 predefinedReasonId?: number
58
59 state?: VideoAbuseState
60 videoIs?: VideoAbuseVideoIs
61
62 // accountIds
63 serverAccountId: number
64 userAccountId: number
65 }) => {
66 const where = {
67 reporterAccountId: {
68 [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
69 }
70 }
71
72 if (options.search) {
73 Object.assign(where, {
74 [Op.or]: [
75 {
76 [Op.and]: [
77 { videoId: { [Op.not]: null } },
78 searchAttribute(options.search, '$Video.name$')
79 ]
80 },
81 {
82 [Op.and]: [
83 { videoId: { [Op.not]: null } },
84 searchAttribute(options.search, '$Video.VideoChannel.name$')
85 ]
86 },
87 {
88 [Op.and]: [
89 { deletedVideo: { [Op.not]: null } },
90 { deletedVideo: searchAttribute(options.search, 'name') }
91 ]
92 },
93 {
94 [Op.and]: [
95 { deletedVideo: { [Op.not]: null } },
96 { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } }
97 ]
98 },
99 searchAttribute(options.search, '$Account.name$')
100 ]
101 })
102 }
103
104 if (options.id) Object.assign(where, { id: options.id })
105 if (options.state) Object.assign(where, { state: options.state })
106
107 if (options.videoIs === 'deleted') {
108 Object.assign(where, {
109 deletedVideo: {
110 [Op.not]: null
111 }
112 })
113 }
114
115 if (options.predefinedReasonId) {
116 Object.assign(where, {
117 predefinedReasons: {
118 [Op.contains]: [ options.predefinedReasonId ]
119 }
120 })
121 }
122
123 const onlyBlacklisted = options.videoIs === 'blacklisted'
124
125 return {
126 attributes: {
127 include: [
128 [
129 // we don't care about this count for deleted videos, so there are not included
130 literal(
131 '(' +
132 'SELECT count(*) ' +
133 'FROM "videoAbuse" ' +
134 'WHERE "videoId" = "VideoAbuseModel"."videoId" ' +
135 ')'
136 ),
137 'countReportsForVideo'
138 ],
139 [
140 // we don't care about this count for deleted videos, so there are not included
141 literal(
142 '(' +
143 'SELECT t.nth ' +
144 'FROM ( ' +
145 'SELECT id, ' +
146 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
147 'FROM "videoAbuse" ' +
148 ') t ' +
149 'WHERE t.id = "VideoAbuseModel".id ' +
150 ')'
151 ),
152 'nthReportForVideo'
153 ],
154 [
155 literal(
156 '(' +
157 'SELECT count("videoAbuse"."id") ' +
158 'FROM "videoAbuse" ' +
159 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
160 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
161 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
162 'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' +
163 ')'
164 ),
165 'countReportsForReporter__video'
166 ],
167 [
168 literal(
169 '(' +
170 'SELECT count(DISTINCT "videoAbuse"."id") ' +
171 'FROM "videoAbuse" ' +
172 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` +
173 ')'
174 ),
175 'countReportsForReporter__deletedVideo'
176 ],
177 [
178 literal(
179 '(' +
180 'SELECT count(DISTINCT "videoAbuse"."id") ' +
181 'FROM "videoAbuse" ' +
182 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
183 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
184 'INNER JOIN "account" ON ' +
185 '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' +
186 `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
187 ')'
188 ),
189 'countReportsForReportee__video'
190 ],
191 [
192 literal(
193 '(' +
194 'SELECT count(DISTINCT "videoAbuse"."id") ' +
195 'FROM "videoAbuse" ' +
196 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` +
197 `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
198 `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
199 ')'
200 ),
201 'countReportsForReportee__deletedVideo'
202 ]
203 ]
204 },
205 include: [
206 {
207 model: AccountModel,
208 required: true,
209 where: searchAttribute(options.searchReporter, 'name')
210 },
211 {
212 model: VideoModel,
213 required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel),
214 where: searchAttribute(options.searchVideo, 'name'),
215 include: [
216 {
217 model: ThumbnailModel
218 },
219 {
220 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
221 where: searchAttribute(options.searchVideoChannel, 'name'),
222 include: [
223 {
224 model: AccountModel,
225 where: searchAttribute(options.searchReportee, 'name')
226 }
227 ]
228 },
229 {
230 attributes: [ 'id', 'reason', 'unfederated' ],
231 model: VideoBlacklistModel,
232 required: onlyBlacklisted
233 }
234 ]
235 }
236 ],
237 where
238 }
239 }
240}))
241@Table({
242 tableName: 'videoAbuse',
243 indexes: [
244 {
245 fields: [ 'videoId' ]
246 },
247 {
248 fields: [ 'reporterAccountId' ]
249 }
250 ]
251})
252export class VideoAbuseModel extends Model<VideoAbuseModel> {
253
254 @AllowNull(false)
255 @Default(null)
256 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
257 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
258 reason: string
259
260 @AllowNull(false)
261 @Default(null)
262 @Is('VideoAbuseState', value => throwIfNotValid(value, isVideoAbuseStateValid, 'state'))
263 @Column
264 state: VideoAbuseState
265
266 @AllowNull(true)
267 @Default(null)
268 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
269 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
270 moderationComment: string
271
272 @AllowNull(true)
273 @Default(null)
274 @Column(DataType.JSONB)
275 deletedVideo: VideoDetails
276
277 @AllowNull(true)
278 @Default(null)
279 @Column(DataType.ARRAY(DataType.INTEGER))
280 predefinedReasons: VideoAbusePredefinedReasons[]
281
282 @AllowNull(true)
283 @Default(null)
284 @Column
285 startAt: number
286
287 @AllowNull(true)
288 @Default(null)
289 @Column
290 endAt: number
291
292 @CreatedAt
293 createdAt: Date
294
295 @UpdatedAt
296 updatedAt: Date
297
298 @ForeignKey(() => AccountModel)
299 @Column
300 reporterAccountId: number
301
302 @BelongsTo(() => AccountModel, {
303 foreignKey: {
304 allowNull: true
305 },
306 onDelete: 'set null'
307 })
308 Account: AccountModel
309
310 @ForeignKey(() => VideoModel)
311 @Column
312 videoId: number
313
314 @BelongsTo(() => VideoModel, {
315 foreignKey: {
316 allowNull: true
317 },
318 onDelete: 'set null'
319 })
320 Video: VideoModel
321
322 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> {
323 const videoAttributes = {}
324 if (videoId) videoAttributes['videoId'] = videoId
325 if (uuid) videoAttributes['deletedVideo'] = { uuid }
326
327 const query = {
328 where: {
329 id,
330 ...videoAttributes
331 }
332 }
333 return VideoAbuseModel.findOne(query)
334 }
335
336 static listForApi (parameters: {
337 start: number
338 count: number
339 sort: string
340
341 serverAccountId: number
342 user?: MUserAccountId
343
344 id?: number
345 predefinedReason?: VideoAbusePredefinedReasonsString
346 state?: VideoAbuseState
347 videoIs?: VideoAbuseVideoIs
348
349 search?: string
350 searchReporter?: string
351 searchReportee?: string
352 searchVideo?: string
353 searchVideoChannel?: string
354 }) {
355 const {
356 start,
357 count,
358 sort,
359 search,
360 user,
361 serverAccountId,
362 state,
363 videoIs,
364 predefinedReason,
365 searchReportee,
366 searchVideo,
367 searchVideoChannel,
368 searchReporter,
369 id
370 } = parameters
371
372 const userAccountId = user ? user.Account.id : undefined
373 const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined
374
375 const query = {
376 offset: start,
377 limit: count,
378 order: getSort(sort),
379 col: 'VideoAbuseModel.id',
380 distinct: true
381 }
382
383 const filters = {
384 id,
385 predefinedReasonId,
386 search,
387 state,
388 videoIs,
389 searchReportee,
390 searchVideo,
391 searchVideoChannel,
392 searchReporter,
393 serverAccountId,
394 userAccountId
395 }
396
397 return VideoAbuseModel
398 .scope([
399 { method: [ ScopeNames.FOR_API, filters ] }
400 ])
401 .findAndCountAll(query)
402 .then(({ rows, count }) => {
403 return { total: count, data: rows }
404 })
405 }
406
407 toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
408 const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
409 const countReportsForVideo = this.get('countReportsForVideo') as number
410 const nthReportForVideo = this.get('nthReportForVideo') as number
411 const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
412 const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number
413 const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
414 const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
415
416 const video = this.Video
417 ? this.Video
418 : this.deletedVideo
419
420 return {
421 id: this.id,
422 reason: this.reason,
423 predefinedReasons,
424 reporterAccount: this.Account.toFormattedJSON(),
425 state: {
426 id: this.state,
427 label: VideoAbuseModel.getStateLabel(this.state)
428 },
429 moderationComment: this.moderationComment,
430 video: {
431 id: video.id,
432 uuid: video.uuid,
433 name: video.name,
434 nsfw: video.nsfw,
435 deleted: !this.Video,
436 blacklisted: this.Video?.isBlacklisted() || false,
437 thumbnailPath: this.Video?.getMiniatureStaticPath(),
438 channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel
439 },
440 createdAt: this.createdAt,
441 updatedAt: this.updatedAt,
442 startAt: this.startAt,
443 endAt: this.endAt,
444 count: countReportsForVideo || 0,
445 nth: nthReportForVideo || 0,
446 countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
447 countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0)
448 }
449 }
450
451 toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject {
452 const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
453
454 const startAt = this.startAt
455 const endAt = this.endAt
456
457 return {
458 type: 'Flag' as 'Flag',
459 content: this.reason,
460 object: this.Video.url,
461 tag: predefinedReasons.map(r => ({
462 type: 'Hashtag' as 'Hashtag',
463 name: r
464 })),
465 startAt,
466 endAt
467 }
468 }
469
470 private static getStateLabel (id: number) {
471 return VIDEO_ABUSE_STATES[id] || 'Unknown'
472 }
473
474 private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] {
475 return (predefinedReasons || [])
476 .filter(r => r in VideoAbusePredefinedReasons)
477 .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString)
478 }
479}
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 9cee64229..03a3cdf81 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -61,6 +61,7 @@ type AvailableWithStatsOptions = {
61} 61}
62 62
63export type SummaryOptions = { 63export type SummaryOptions = {
64 actorRequired?: boolean // Default: true
64 withAccount?: boolean // Default: false 65 withAccount?: boolean // Default: false
65 withAccountBlockerIds?: number[] 66 withAccountBlockerIds?: number[]
66} 67}
@@ -121,7 +122,7 @@ export type SummaryOptions = {
121 { 122 {
122 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 123 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
123 model: ActorModel.unscoped(), 124 model: ActorModel.unscoped(),
124 required: true, 125 required: options.actorRequired ?? true,
125 include: [ 126 include: [
126 { 127 {
127 attributes: [ 'host' ], 128 attributes: [ 'host' ],
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 90625d987..75b914b8c 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,7 +1,20 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { uniq } from 'lodash' 2import { uniq } from 'lodash'
3import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize' 3import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
4import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 4import {
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 ForeignKey,
11 HasMany,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
5import { getServerActor } from '@server/models/application/application' 18import { getServerActor } from '@server/models/application/application'
6import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 19import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
7import { VideoPrivacy } from '@shared/models' 20import { VideoPrivacy } from '@shared/models'
@@ -24,6 +37,7 @@ import {
24 MCommentOwnerVideoReply, 37 MCommentOwnerVideoReply,
25 MVideoImmutable 38 MVideoImmutable
26} from '../../types/models/video' 39} from '../../types/models/video'
40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
27import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 42import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' 43import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
@@ -224,6 +238,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
224 }) 238 })
225 Account: AccountModel 239 Account: AccountModel
226 240
241 @HasMany(() => VideoCommentAbuseModel, {
242 foreignKey: {
243 name: 'videoCommentId',
244 allowNull: true
245 },
246 onDelete: 'set null'
247 })
248 CommentAbuses: VideoCommentAbuseModel[]
249
227 static loadById (id: number, t?: Transaction): Bluebird<MComment> { 250 static loadById (id: number, t?: Transaction): Bluebird<MComment> {
228 const query: FindOptions = { 251 const query: FindOptions = {
229 where: { 252 where: {
@@ -632,7 +655,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
632 id: this.id, 655 id: this.id,
633 url: this.url, 656 url: this.url,
634 text: this.text, 657 text: this.text,
635 threadId: this.originCommentId || this.id, 658 threadId: this.getThreadId(),
636 inReplyToCommentId: this.inReplyToCommentId || null, 659 inReplyToCommentId: this.inReplyToCommentId || null,
637 videoId: this.videoId, 660 videoId: this.videoId,
638 createdAt: this.createdAt, 661 createdAt: this.createdAt,
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts
index 984b0e6af..466890364 100644
--- a/server/models/video/video-query-builder.ts
+++ b/server/models/video/video-query-builder.ts
@@ -327,7 +327,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
327 attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"') 327 attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
328 } 328 }
329 329
330 order = buildOrder(model, options.sort) 330 order = buildOrder(options.sort)
331 suffix += `${order} ` 331 suffix += `${order} `
332 } 332 }
333 333
@@ -357,7 +357,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
357 return { query, replacements, order } 357 return { query, replacements, order }
358} 358}
359 359
360function buildOrder (model: typeof Model, value: string) { 360function buildOrder (value: string) {
361 const { direction, field } = buildDirectionAndField(value) 361 const { direction, field } = buildDirectionAndField(value)
362 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field) 362 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
363 363
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index e2718300e..43609587c 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,4 +1,5 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { remove } from 'fs-extra'
2import { maxBy, minBy, pick } from 'lodash' 3import { maxBy, minBy, pick } from 'lodash'
3import { join } from 'path' 4import { join } from 'path'
4import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 5import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
@@ -23,10 +24,18 @@ import {
23 Table, 24 Table,
24 UpdatedAt 25 UpdatedAt
25} from 'sequelize-typescript' 26} from 'sequelize-typescript'
26import { UserRight, VideoPrivacy, VideoState, ResultList } from '../../../shared' 27import { buildNSFWFilter } from '@server/helpers/express-utils'
28import { getPrivaciesForFederation, isPrivacyForFederation } from '@server/helpers/video'
29import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
30import { getServerActor } from '@server/models/application/application'
31import { ModelCache } from '@server/models/model-cache'
32import { VideoFile } from '@shared/models/videos/video-file.model'
33import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
27import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 34import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
28import { Video, VideoDetails } from '../../../shared/models/videos' 35import { Video, VideoDetails } from '../../../shared/models/videos'
36import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
29import { VideoFilter } from '../../../shared/models/videos/video-query.type' 37import { VideoFilter } from '../../../shared/models/videos/video-query.type'
38import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
30import { peertubeTruncate } from '../../helpers/core-utils' 39import { peertubeTruncate } from '../../helpers/core-utils'
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 40import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { isBooleanValid } from '../../helpers/custom-validators/misc' 41import { isBooleanValid } from '../../helpers/custom-validators/misc'
@@ -43,6 +52,7 @@ import {
43} from '../../helpers/custom-validators/videos' 52} from '../../helpers/custom-validators/videos'
44import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' 53import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
45import { logger } from '../../helpers/logger' 54import { logger } from '../../helpers/logger'
55import { CONFIG } from '../../initializers/config'
46import { 56import {
47 ACTIVITY_PUB, 57 ACTIVITY_PUB,
48 API_VERSION, 58 API_VERSION,
@@ -59,40 +69,6 @@ import {
59 WEBSERVER 69 WEBSERVER
60} from '../../initializers/constants' 70} from '../../initializers/constants'
61import { sendDeleteVideo } from '../../lib/activitypub/send' 71import { sendDeleteVideo } from '../../lib/activitypub/send'
62import { AccountModel } from '../account/account'
63import { AccountVideoRateModel } from '../account/account-video-rate'
64import { ActorModel } from '../activitypub/actor'
65import { AvatarModel } from '../avatar/avatar'
66import { ServerModel } from '../server/server'
67import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
68import { TagModel } from './tag'
69import { VideoAbuseModel } from './video-abuse'
70import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
71import { VideoCommentModel } from './video-comment'
72import { VideoFileModel } from './video-file'
73import { VideoShareModel } from './video-share'
74import { VideoTagModel } from './video-tag'
75import { ScheduleVideoUpdateModel } from './schedule-video-update'
76import { VideoCaptionModel } from './video-caption'
77import { VideoBlacklistModel } from './video-blacklist'
78import { remove } from 'fs-extra'
79import { VideoViewModel } from './video-view'
80import { VideoRedundancyModel } from '../redundancy/video-redundancy'
81import {
82 videoFilesModelToFormattedJSON,
83 VideoFormattingJSONOptions,
84 videoModelToActivityPubObject,
85 videoModelToFormattedDetailsJSON,
86 videoModelToFormattedJSON
87} from './video-format-utils'
88import { UserVideoHistoryModel } from '../account/user-video-history'
89import { VideoImportModel } from './video-import'
90import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
91import { VideoPlaylistElementModel } from './video-playlist-element'
92import { CONFIG } from '../../initializers/config'
93import { ThumbnailModel } from './thumbnail'
94import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
95import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
96import { 72import {
97 MChannel, 73 MChannel,
98 MChannelAccountDefault, 74 MChannelAccountDefault,
@@ -118,15 +94,39 @@ import {
118 MVideoWithFile, 94 MVideoWithFile,
119 MVideoWithRights 95 MVideoWithRights
120} from '../../types/models' 96} from '../../types/models'
121import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
122import { MThumbnail } from '../../types/models/video/thumbnail' 97import { MThumbnail } from '../../types/models/video/thumbnail'
123import { VideoFile } from '@shared/models/videos/video-file.model' 98import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
124import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 99import { VideoAbuseModel } from '../abuse/video-abuse'
125import { ModelCache } from '@server/models/model-cache' 100import { AccountModel } from '../account/account'
101import { AccountVideoRateModel } from '../account/account-video-rate'
102import { UserVideoHistoryModel } from '../account/user-video-history'
103import { ActorModel } from '../activitypub/actor'
104import { AvatarModel } from '../avatar/avatar'
105import { VideoRedundancyModel } from '../redundancy/video-redundancy'
106import { ServerModel } from '../server/server'
107import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
108import { ScheduleVideoUpdateModel } from './schedule-video-update'
109import { TagModel } from './tag'
110import { ThumbnailModel } from './thumbnail'
111import { VideoBlacklistModel } from './video-blacklist'
112import { VideoCaptionModel } from './video-caption'
113import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
114import { VideoCommentModel } from './video-comment'
115import { VideoFileModel } from './video-file'
116import {
117 videoFilesModelToFormattedJSON,
118 VideoFormattingJSONOptions,
119 videoModelToActivityPubObject,
120 videoModelToFormattedDetailsJSON,
121 videoModelToFormattedJSON
122} from './video-format-utils'
123import { VideoImportModel } from './video-import'
124import { VideoPlaylistElementModel } from './video-playlist-element'
126import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder' 125import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
127import { buildNSFWFilter } from '@server/helpers/express-utils' 126import { VideoShareModel } from './video-share'
128import { getServerActor } from '@server/models/application/application' 127import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
129import { getPrivaciesForFederation, isPrivacyForFederation } from "@server/helpers/video" 128import { VideoTagModel } from './video-tag'
129import { VideoViewModel } from './video-view'
130 130
131export enum ScopeNames { 131export enum ScopeNames {
132 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', 132 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
@@ -803,14 +803,14 @@ export class VideoModel extends Model<VideoModel> {
803 static async saveEssentialDataToAbuses (instance: VideoModel, options) { 803 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
804 const tasks: Promise<any>[] = [] 804 const tasks: Promise<any>[] = []
805 805
806 logger.info('Saving video abuses details of video %s.', instance.url)
807
808 if (!Array.isArray(instance.VideoAbuses)) { 806 if (!Array.isArray(instance.VideoAbuses)) {
809 instance.VideoAbuses = await instance.$get('VideoAbuses') 807 instance.VideoAbuses = await instance.$get('VideoAbuses')
810 808
811 if (instance.VideoAbuses.length === 0) return undefined 809 if (instance.VideoAbuses.length === 0) return undefined
812 } 810 }
813 811
812 logger.info('Saving video abuses details of video %s.', instance.url)
813
814 const details = instance.toFormattedDetailsJSON() 814 const details = instance.toFormattedDetailsJSON()
815 815
816 for (const abuse of instance.VideoAbuses) { 816 for (const abuse of instance.VideoAbuses) {
diff --git a/server/tests/api/check-params/abuses.ts b/server/tests/api/check-params/abuses.ts
new file mode 100644
index 000000000..8964c0ab2
--- /dev/null
+++ b/server/tests/api/check-params/abuses.ts
@@ -0,0 +1,269 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { AbuseCreate, AbuseState } from '@shared/models'
5import {
6 cleanupTests,
7 createUser,
8 deleteAbuse,
9 flushAndRunServer,
10 makeGetRequest,
11 makePostBodyRequest,
12 ServerInfo,
13 setAccessTokensToServers,
14 updateAbuse,
15 uploadVideo,
16 userLogin
17} from '../../../../shared/extra-utils'
18import {
19 checkBadCountPagination,
20 checkBadSortPagination,
21 checkBadStartPagination
22} from '../../../../shared/extra-utils/requests/check-api-params'
23
24describe('Test abuses API validators', function () {
25 const basePath = '/api/v1/abuses/'
26
27 let server: ServerInfo
28 let userAccessToken = ''
29 let abuseId: number
30
31 // ---------------------------------------------------------------
32
33 before(async function () {
34 this.timeout(30000)
35
36 server = await flushAndRunServer(1)
37
38 await setAccessTokensToServers([ server ])
39
40 const username = 'user1'
41 const password = 'my super password'
42 await createUser({ url: server.url, accessToken: server.accessToken, username: username, password: password })
43 userAccessToken = await userLogin(server, { username, password })
44
45 const res = await uploadVideo(server.url, server.accessToken, {})
46 server.video = res.body.video
47 })
48
49 describe('When listing abuses', function () {
50 const path = basePath
51
52 it('Should fail with a bad start pagination', async function () {
53 await checkBadStartPagination(server.url, path, server.accessToken)
54 })
55
56 it('Should fail with a bad count pagination', async function () {
57 await checkBadCountPagination(server.url, path, server.accessToken)
58 })
59
60 it('Should fail with an incorrect sort', async function () {
61 await checkBadSortPagination(server.url, path, server.accessToken)
62 })
63
64 it('Should fail with a non authenticated user', async function () {
65 await makeGetRequest({
66 url: server.url,
67 path,
68 statusCodeExpected: 401
69 })
70 })
71
72 it('Should fail with a non admin user', async function () {
73 await makeGetRequest({
74 url: server.url,
75 path,
76 token: userAccessToken,
77 statusCodeExpected: 403
78 })
79 })
80
81 it('Should fail with a bad id filter', async function () {
82 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { id: 'toto' } })
83 })
84
85 it('Should fail with a bad filter', async function () {
86 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'toto' } })
87 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'videos' } })
88 })
89
90 it('Should fail with bad predefined reason', async function () {
91 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { predefinedReason: 'violentOrRepulsives' } })
92 })
93
94 it('Should fail with a bad state filter', async function () {
95 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 'toto' } })
96 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 0 } })
97 })
98
99 it('Should fail with a bad videoIs filter', async function () {
100 await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { videoIs: 'toto' } })
101 })
102
103 it('Should succeed with the correct params', async function () {
104 const query = {
105 id: 13,
106 predefinedReason: 'violentOrRepulsive',
107 filter: 'comment',
108 state: 2,
109 videoIs: 'deleted'
110 }
111
112 await makeGetRequest({ url: server.url, path, token: server.accessToken, query, statusCodeExpected: 200 })
113 })
114 })
115
116 describe('When reporting an abuse', function () {
117 const path = basePath
118
119 it('Should fail with nothing', async function () {
120 const fields = {}
121 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
122 })
123
124 it('Should fail with a wrong video', async function () {
125 const fields = { video: { id: 'blabla' }, reason: 'my super reason' }
126 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
127 })
128
129 it('Should fail with an unknown video', async function () {
130 const fields = { video: { id: 42 }, reason: 'my super reason' }
131 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
132 })
133
134 it('Should fail with a wrong comment', async function () {
135 const fields = { comment: { id: 'blabla' }, reason: 'my super reason' }
136 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
137 })
138
139 it('Should fail with an unknown comment', async function () {
140 const fields = { comment: { id: 42 }, reason: 'my super reason' }
141 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
142 })
143
144 it('Should fail with a wrong account', async function () {
145 const fields = { account: { id: 'blabla' }, reason: 'my super reason' }
146 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
147 })
148
149 it('Should fail with an unknown account', async function () {
150 const fields = { account: { id: 42 }, reason: 'my super reason' }
151 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
152 })
153
154 it('Should fail with not account, comment or video', async function () {
155 const fields = { reason: 'my super reason' }
156 await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 400 })
157 })
158
159 it('Should fail with a non authenticated user', async function () {
160 const fields = { video: { id: server.video.id }, reason: 'my super reason' }
161
162 await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 })
163 })
164
165 it('Should fail with a reason too short', async function () {
166 const fields = { video: { id: server.video.id }, reason: 'h' }
167
168 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
169 })
170
171 it('Should fail with a too big reason', async function () {
172 const fields = { video: { id: server.video.id }, reason: 'super'.repeat(605) }
173
174 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
175 })
176
177 it('Should succeed with the correct parameters (basic)', async function () {
178 const fields: AbuseCreate = { video: { id: server.video.id }, reason: 'my super reason' }
179
180 const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
181 abuseId = res.body.abuse.id
182 })
183
184 it('Should fail with a wrong predefined reason', async function () {
185 const fields = { video: { id: server.video.id }, reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] }
186
187 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
188 })
189
190 it('Should fail with negative timestamps', async function () {
191 const fields = { video: { id: server.video.id, startAt: -1 }, reason: 'my super reason' }
192
193 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
194 })
195
196 it('Should fail mith misordered startAt/endAt', async function () {
197 const fields = { video: { id: server.video.id, startAt: 5, endAt: 1 }, reason: 'my super reason' }
198
199 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
200 })
201
202 it('Should succeed with the corret parameters (advanced)', async function () {
203 const fields: AbuseCreate = {
204 video: {
205 id: server.video.id,
206 startAt: 1,
207 endAt: 5
208 },
209 reason: 'my super reason',
210 predefinedReasons: [ 'serverRules' ]
211 }
212
213 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
214 })
215 })
216
217 describe('When updating an abuse', function () {
218
219 it('Should fail with a non authenticated user', async function () {
220 await updateAbuse(server.url, 'blabla', abuseId, {}, 401)
221 })
222
223 it('Should fail with a non admin user', async function () {
224 await updateAbuse(server.url, userAccessToken, abuseId, {}, 403)
225 })
226
227 it('Should fail with a bad abuse id', async function () {
228 await updateAbuse(server.url, server.accessToken, 45, {}, 404)
229 })
230
231 it('Should fail with a bad state', async function () {
232 const body = { state: 5 }
233 await updateAbuse(server.url, server.accessToken, abuseId, body, 400)
234 })
235
236 it('Should fail with a bad moderation comment', async function () {
237 const body = { moderationComment: 'b'.repeat(3001) }
238 await updateAbuse(server.url, server.accessToken, abuseId, body, 400)
239 })
240
241 it('Should succeed with the correct params', async function () {
242 const body = { state: AbuseState.ACCEPTED }
243 await updateAbuse(server.url, server.accessToken, abuseId, body)
244 })
245 })
246
247 describe('When deleting a video abuse', function () {
248
249 it('Should fail with a non authenticated user', async function () {
250 await deleteAbuse(server.url, 'blabla', abuseId, 401)
251 })
252
253 it('Should fail with a non admin user', async function () {
254 await deleteAbuse(server.url, userAccessToken, abuseId, 403)
255 })
256
257 it('Should fail with a bad abuse id', async function () {
258 await deleteAbuse(server.url, server.accessToken, 45, 404)
259 })
260
261 it('Should succeed with the correct params', async function () {
262 await deleteAbuse(server.url, server.accessToken, abuseId)
263 })
264 })
265
266 after(async function () {
267 await cleanupTests([ server ])
268 })
269})
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 93ffd98b1..0ee1f27aa 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -1,3 +1,4 @@
1import './abuses'
1import './accounts' 2import './accounts'
2import './blocklist' 3import './blocklist'
3import './bulk' 4import './bulk'
diff --git a/server/tests/api/check-params/user-notifications.ts b/server/tests/api/check-params/user-notifications.ts
index 2048fa667..883b1d29c 100644
--- a/server/tests/api/check-params/user-notifications.ts
+++ b/server/tests/api/check-params/user-notifications.ts
@@ -164,7 +164,7 @@ describe('Test user notifications API validators', function () {
164 const correctFields: UserNotificationSetting = { 164 const correctFields: UserNotificationSetting = {
165 newVideoFromSubscription: UserNotificationSettingValue.WEB, 165 newVideoFromSubscription: UserNotificationSettingValue.WEB,
166 newCommentOnMyVideo: UserNotificationSettingValue.WEB, 166 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
167 videoAbuseAsModerator: UserNotificationSettingValue.WEB, 167 abuseAsModerator: UserNotificationSettingValue.WEB,
168 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB, 168 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB,
169 blacklistOnMyVideo: UserNotificationSettingValue.WEB, 169 blacklistOnMyVideo: UserNotificationSettingValue.WEB,
170 myVideoImportFinished: UserNotificationSettingValue.WEB, 170 myVideoImportFinished: UserNotificationSettingValue.WEB,
diff --git a/server/tests/api/check-params/video-abuses.ts b/server/tests/api/check-params/video-abuses.ts
index 557bf20eb..3b361ca79 100644
--- a/server/tests/api/check-params/video-abuses.ts
+++ b/server/tests/api/check-params/video-abuses.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4 4import { AbuseState, VideoAbuseCreate } from '@shared/models'
5import { 5import {
6 cleanupTests, 6 cleanupTests,
7 createUser, 7 createUser,
@@ -20,7 +20,8 @@ import {
20 checkBadSortPagination, 20 checkBadSortPagination,
21 checkBadStartPagination 21 checkBadStartPagination
22} from '../../../../shared/extra-utils/requests/check-api-params' 22} from '../../../../shared/extra-utils/requests/check-api-params'
23import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos' 23
24// FIXME: deprecated in 2.3. Remove this controller
24 25
25describe('Test video abuses API validators', function () { 26describe('Test video abuses API validators', function () {
26 let server: ServerInfo 27 let server: ServerInfo
@@ -136,7 +137,7 @@ describe('Test video abuses API validators', function () {
136 const fields = { reason: 'my super reason' } 137 const fields = { reason: 'my super reason' }
137 138
138 const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) 139 const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
139 videoAbuseId = res.body.videoAbuse.id 140 videoAbuseId = res.body.abuse.id
140 }) 141 })
141 142
142 it('Should fail with a wrong predefined reason', async function () { 143 it('Should fail with a wrong predefined reason', async function () {
@@ -151,12 +152,6 @@ describe('Test video abuses API validators', function () {
151 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 152 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
152 }) 153 })
153 154
154 it('Should fail mith misordered startAt/endAt', async function () {
155 const fields = { reason: 'my super reason', startAt: 5, endAt: 1 }
156
157 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
158 })
159
160 it('Should succeed with the corret parameters (advanced)', async function () { 155 it('Should succeed with the corret parameters (advanced)', async function () {
161 const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 } 156 const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 }
162 157
@@ -190,7 +185,7 @@ describe('Test video abuses API validators', function () {
190 }) 185 })
191 186
192 it('Should succeed with the correct params', async function () { 187 it('Should succeed with the correct params', async function () {
193 const body = { state: VideoAbuseState.ACCEPTED } 188 const body = { state: AbuseState.ACCEPTED }
194 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body) 189 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body)
195 }) 190 })
196 }) 191 })
diff --git a/server/tests/api/ci-4.sh b/server/tests/api/ci-4.sh
index 14a014f07..4998de364 100644
--- a/server/tests/api/ci-4.sh
+++ b/server/tests/api/ci-4.sh
@@ -2,6 +2,7 @@
2 2
3set -eu 3set -eu
4 4
5activitypubFiles=$(find server/tests/api/moderation -type f | grep -v index.ts | xargs echo)
5redundancyFiles=$(find server/tests/api/redundancy -type f | grep -v index.ts | xargs echo) 6redundancyFiles=$(find server/tests/api/redundancy -type f | grep -v index.ts | xargs echo)
6activitypubFiles=$(find server/tests/api/activitypub -type f | grep -v index.ts | xargs echo) 7activitypubFiles=$(find server/tests/api/activitypub -type f | grep -v index.ts | xargs echo)
7 8
diff --git a/server/tests/api/index.ts b/server/tests/api/index.ts
index bac77ab2e..b62e2f5f7 100644
--- a/server/tests/api/index.ts
+++ b/server/tests/api/index.ts
@@ -1,6 +1,7 @@
1// Order of the tests we want to execute 1// Order of the tests we want to execute
2import './activitypub' 2import './activitypub'
3import './check-params' 3import './check-params'
4import './moderation'
4import './notifications' 5import './notifications'
5import './redundancy' 6import './redundancy'
6import './search' 7import './search'
diff --git a/server/tests/api/moderation/abuses.ts b/server/tests/api/moderation/abuses.ts
new file mode 100644
index 000000000..f186f7ea0
--- /dev/null
+++ b/server/tests/api/moderation/abuses.ts
@@ -0,0 +1,777 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { Abuse, AbuseFilter, AbusePredefinedReasonsString, AbuseState, VideoComment, Account } from '@shared/models'
6import {
7 addVideoCommentThread,
8 cleanupTests,
9 createUser,
10 deleteAbuse,
11 deleteVideoComment,
12 flushAndRunMultipleServers,
13 getAbusesList,
14 getVideoCommentThreads,
15 getVideoIdFromUUID,
16 getVideosList,
17 immutableAssign,
18 removeVideo,
19 reportAbuse,
20 ServerInfo,
21 setAccessTokensToServers,
22 updateAbuse,
23 uploadVideo,
24 uploadVideoAndGetId,
25 userLogin,
26 getAccount,
27 removeUser,
28 generateUserAccessToken
29} from '../../../../shared/extra-utils/index'
30import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
31import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
32import {
33 addAccountToServerBlocklist,
34 addServerToServerBlocklist,
35 removeAccountFromServerBlocklist,
36 removeServerFromServerBlocklist
37} from '../../../../shared/extra-utils/users/blocklist'
38
39const expect = chai.expect
40
41describe('Test abuses', function () {
42 let servers: ServerInfo[] = []
43 let abuseServer1: Abuse
44 let abuseServer2: Abuse
45
46 before(async function () {
47 this.timeout(50000)
48
49 // Run servers
50 servers = await flushAndRunMultipleServers(2)
51
52 // Get the access tokens
53 await setAccessTokensToServers(servers)
54
55 // Server 1 and server 2 follow each other
56 await doubleFollow(servers[0], servers[1])
57 })
58
59 describe('Video abuses', function () {
60
61 before(async function () {
62 this.timeout(50000)
63
64 // Upload some videos on each servers
65 const video1Attributes = {
66 name: 'my super name for server 1',
67 description: 'my super description for server 1'
68 }
69 await uploadVideo(servers[0].url, servers[0].accessToken, video1Attributes)
70
71 const video2Attributes = {
72 name: 'my super name for server 2',
73 description: 'my super description for server 2'
74 }
75 await uploadVideo(servers[1].url, servers[1].accessToken, video2Attributes)
76
77 // Wait videos propagation, server 2 has transcoding enabled
78 await waitJobs(servers)
79
80 const res = await getVideosList(servers[0].url)
81 const videos = res.body.data
82
83 expect(videos.length).to.equal(2)
84
85 servers[0].video = videos.find(video => video.name === 'my super name for server 1')
86 servers[1].video = videos.find(video => video.name === 'my super name for server 2')
87 })
88
89 it('Should not have abuses', async function () {
90 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
91
92 expect(res.body.total).to.equal(0)
93 expect(res.body.data).to.be.an('array')
94 expect(res.body.data.length).to.equal(0)
95 })
96
97 it('Should report abuse on a local video', async function () {
98 this.timeout(15000)
99
100 const reason = 'my super bad reason'
101 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: servers[0].video.id, reason })
102
103 // We wait requests propagation, even if the server 1 is not supposed to make a request to server 2
104 await waitJobs(servers)
105 })
106
107 it('Should have 1 video abuses on server 1 and 0 on server 2', async function () {
108 const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
109
110 expect(res1.body.total).to.equal(1)
111 expect(res1.body.data).to.be.an('array')
112 expect(res1.body.data.length).to.equal(1)
113
114 const abuse: Abuse = res1.body.data[0]
115 expect(abuse.reason).to.equal('my super bad reason')
116
117 expect(abuse.reporterAccount.name).to.equal('root')
118 expect(abuse.reporterAccount.host).to.equal(servers[0].host)
119
120 expect(abuse.video.id).to.equal(servers[0].video.id)
121 expect(abuse.video.channel).to.exist
122
123 expect(abuse.comment).to.be.null
124
125 expect(abuse.flaggedAccount.name).to.equal('root')
126 expect(abuse.flaggedAccount.host).to.equal(servers[0].host)
127
128 expect(abuse.video.countReports).to.equal(1)
129 expect(abuse.video.nthReport).to.equal(1)
130
131 expect(abuse.countReportsForReporter).to.equal(1)
132 expect(abuse.countReportsForReportee).to.equal(1)
133
134 const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
135 expect(res2.body.total).to.equal(0)
136 expect(res2.body.data).to.be.an('array')
137 expect(res2.body.data.length).to.equal(0)
138 })
139
140 it('Should report abuse on a remote video', async function () {
141 this.timeout(10000)
142
143 const reason = 'my super bad reason 2'
144 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: servers[1].video.id, reason })
145
146 // We wait requests propagation
147 await waitJobs(servers)
148 })
149
150 it('Should have 2 video abuses on server 1 and 1 on server 2', async function () {
151 const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
152
153 expect(res1.body.total).to.equal(2)
154 expect(res1.body.data.length).to.equal(2)
155
156 const abuse1: Abuse = res1.body.data[0]
157 expect(abuse1.reason).to.equal('my super bad reason')
158 expect(abuse1.reporterAccount.name).to.equal('root')
159 expect(abuse1.reporterAccount.host).to.equal(servers[0].host)
160
161 expect(abuse1.video.id).to.equal(servers[0].video.id)
162 expect(abuse1.video.countReports).to.equal(1)
163 expect(abuse1.video.nthReport).to.equal(1)
164
165 expect(abuse1.comment).to.be.null
166
167 expect(abuse1.flaggedAccount.name).to.equal('root')
168 expect(abuse1.flaggedAccount.host).to.equal(servers[0].host)
169
170 expect(abuse1.state.id).to.equal(AbuseState.PENDING)
171 expect(abuse1.state.label).to.equal('Pending')
172 expect(abuse1.moderationComment).to.be.null
173
174 const abuse2: Abuse = res1.body.data[1]
175 expect(abuse2.reason).to.equal('my super bad reason 2')
176
177 expect(abuse2.reporterAccount.name).to.equal('root')
178 expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
179
180 expect(abuse2.video.id).to.equal(servers[1].video.id)
181
182 expect(abuse2.comment).to.be.null
183
184 expect(abuse2.flaggedAccount.name).to.equal('root')
185 expect(abuse2.flaggedAccount.host).to.equal(servers[1].host)
186
187 expect(abuse2.state.id).to.equal(AbuseState.PENDING)
188 expect(abuse2.state.label).to.equal('Pending')
189 expect(abuse2.moderationComment).to.be.null
190
191 const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
192 expect(res2.body.total).to.equal(1)
193 expect(res2.body.data.length).to.equal(1)
194
195 abuseServer2 = res2.body.data[0]
196 expect(abuseServer2.reason).to.equal('my super bad reason 2')
197 expect(abuseServer2.reporterAccount.name).to.equal('root')
198 expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
199
200 expect(abuse2.flaggedAccount.name).to.equal('root')
201 expect(abuse2.flaggedAccount.host).to.equal(servers[1].host)
202
203 expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
204 expect(abuseServer2.state.label).to.equal('Pending')
205 expect(abuseServer2.moderationComment).to.be.null
206 })
207
208 it('Should hide video abuses from blocked accounts', async function () {
209 this.timeout(10000)
210
211 {
212 const videoId = await getVideoIdFromUUID(servers[1].url, servers[0].video.uuid)
213 await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, videoId, reason: 'will mute this' })
214 await waitJobs(servers)
215
216 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
217 expect(res.body.total).to.equal(3)
218 }
219
220 const accountToBlock = 'root@' + servers[1].host
221
222 {
223 await addAccountToServerBlocklist(servers[0].url, servers[0].accessToken, accountToBlock)
224
225 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
226 expect(res.body.total).to.equal(2)
227
228 const abuse = res.body.data.find(a => a.reason === 'will mute this')
229 expect(abuse).to.be.undefined
230 }
231
232 {
233 await removeAccountFromServerBlocklist(servers[0].url, servers[0].accessToken, accountToBlock)
234
235 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
236 expect(res.body.total).to.equal(3)
237 }
238 })
239
240 it('Should hide video abuses from blocked servers', async function () {
241 const serverToBlock = servers[1].host
242
243 {
244 await addServerToServerBlocklist(servers[0].url, servers[0].accessToken, servers[1].host)
245
246 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
247 expect(res.body.total).to.equal(2)
248
249 const abuse = res.body.data.find(a => a.reason === 'will mute this')
250 expect(abuse).to.be.undefined
251 }
252
253 {
254 await removeServerFromServerBlocklist(servers[0].url, servers[0].accessToken, serverToBlock)
255
256 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
257 expect(res.body.total).to.equal(3)
258 }
259 })
260
261 it('Should keep the video abuse when deleting the video', async function () {
262 this.timeout(10000)
263
264 await removeVideo(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid)
265
266 await waitJobs(servers)
267
268 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
269 expect(res.body.total).to.equal(2, "wrong number of videos returned")
270 expect(res.body.data).to.have.lengthOf(2, "wrong number of videos returned")
271
272 const abuse: Abuse = res.body.data[0]
273 expect(abuse.id).to.equal(abuseServer2.id, "wrong origin server id for first video")
274 expect(abuse.video.id).to.equal(abuseServer2.video.id, "wrong video id")
275 expect(abuse.video.channel).to.exist
276 expect(abuse.video.deleted).to.be.true
277 })
278
279 it('Should include counts of reports from reporter and reportee', async function () {
280 this.timeout(10000)
281
282 // register a second user to have two reporters/reportees
283 const user = { username: 'user2', password: 'password' }
284 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, ...user })
285 const userAccessToken = await userLogin(servers[0], user)
286
287 // upload a third video via this user
288 const video3Attributes = {
289 name: 'my second super name for server 1',
290 description: 'my second super description for server 1'
291 }
292 await uploadVideo(servers[0].url, userAccessToken, video3Attributes)
293
294 const res1 = await getVideosList(servers[0].url)
295 const videos = res1.body.data
296 const video3 = videos.find(video => video.name === 'my second super name for server 1')
297
298 // resume with the test
299 const reason3 = 'my super bad reason 3'
300 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: video3.id, reason: reason3 })
301
302 const reason4 = 'my super bad reason 4'
303 await reportAbuse({ url: servers[0].url, token: userAccessToken, videoId: servers[0].video.id, reason: reason4 })
304
305 {
306 const res2 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
307 const abuses = res2.body.data as Abuse[]
308
309 const abuseVideo3 = res2.body.data.find(a => a.video.id === video3.id)
310 expect(abuseVideo3).to.not.be.undefined
311 expect(abuseVideo3.video.countReports).to.equal(1, "wrong reports count for video 3")
312 expect(abuseVideo3.video.nthReport).to.equal(1, "wrong report position in report list for video 3")
313 expect(abuseVideo3.countReportsForReportee).to.equal(1, "wrong reports count for reporter on video 3 abuse")
314 expect(abuseVideo3.countReportsForReporter).to.equal(3, "wrong reports count for reportee on video 3 abuse")
315
316 const abuseServer1 = abuses.find(a => a.video.id === servers[0].video.id)
317 expect(abuseServer1.countReportsForReportee).to.equal(3, "wrong reports count for reporter on video 1 abuse")
318 }
319 })
320
321 it('Should list predefined reasons as well as timestamps for the reported video', async function () {
322 this.timeout(10000)
323
324 const reason5 = 'my super bad reason 5'
325 const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
326 const createdAbuse = (await reportAbuse({
327 url: servers[0].url,
328 token: servers[0].accessToken,
329 videoId: servers[0].video.id,
330 reason: reason5,
331 predefinedReasons: predefinedReasons5,
332 startAt: 1,
333 endAt: 5
334 })).body.abuse
335
336 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
337
338 {
339 const abuse = (res.body.data as Abuse[]).find(a => a.id === createdAbuse.id)
340 expect(abuse.reason).to.equals(reason5)
341 expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported")
342 expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
343 expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
344 }
345 })
346
347 it('Should delete the video abuse', async function () {
348 this.timeout(10000)
349
350 await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
351
352 await waitJobs(servers)
353
354 {
355 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
356 expect(res.body.total).to.equal(1)
357 expect(res.body.data.length).to.equal(1)
358 expect(res.body.data[0].id).to.not.equal(abuseServer2.id)
359 }
360
361 {
362 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
363 expect(res.body.total).to.equal(6)
364 }
365 })
366
367 it('Should list and filter video abuses', async function () {
368 this.timeout(10000)
369
370 async function list (query: Omit<Parameters<typeof getAbusesList>[0], 'url' | 'token'>) {
371 const options = {
372 url: servers[0].url,
373 token: servers[0].accessToken
374 }
375
376 Object.assign(options, query)
377
378 const res = await getAbusesList(options)
379
380 return res.body.data as Abuse[]
381 }
382
383 expect(await list({ id: 56 })).to.have.lengthOf(0)
384 expect(await list({ id: 1 })).to.have.lengthOf(1)
385
386 expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4)
387 expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0)
388
389 expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1)
390
391 expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4)
392 expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0)
393
394 expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
395 expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5)
396
397 expect(await list({ searchReportee: 'root' })).to.have.lengthOf(5)
398 expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
399
400 expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
401 expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
402
403 expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0)
404 expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6)
405
406 expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
407 expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)
408 })
409 })
410
411 describe('Comment abuses', function () {
412
413 async function getComment (url: string, videoIdArg: number | string) {
414 const videoId = typeof videoIdArg === 'string'
415 ? await getVideoIdFromUUID(url, videoIdArg)
416 : videoIdArg
417
418 const res = await getVideoCommentThreads(url, videoId, 0, 5)
419
420 return res.body.data[0] as VideoComment
421 }
422
423 before(async function () {
424 this.timeout(50000)
425
426 servers[0].video = await uploadVideoAndGetId({ server: servers[0], videoName: 'server 1' })
427 servers[1].video = await uploadVideoAndGetId({ server: servers[1], videoName: 'server 2' })
428
429 await addVideoCommentThread(servers[0].url, servers[0].accessToken, servers[0].video.id, 'comment server 1')
430 await addVideoCommentThread(servers[1].url, servers[1].accessToken, servers[1].video.id, 'comment server 2')
431
432 await waitJobs(servers)
433 })
434
435 it('Should report abuse on a comment', async function () {
436 this.timeout(15000)
437
438 const comment = await getComment(servers[0].url, servers[0].video.id)
439
440 const reason = 'it is a bad comment'
441 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason })
442
443 await waitJobs(servers)
444 })
445
446 it('Should have 1 comment abuse on server 1 and 0 on server 2', async function () {
447 {
448 const comment = await getComment(servers[0].url, servers[0].video.id)
449 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
450
451 expect(res.body.total).to.equal(1)
452 expect(res.body.data).to.have.lengthOf(1)
453
454 const abuse: Abuse = res.body.data[0]
455 expect(abuse.reason).to.equal('it is a bad comment')
456
457 expect(abuse.reporterAccount.name).to.equal('root')
458 expect(abuse.reporterAccount.host).to.equal(servers[0].host)
459
460 expect(abuse.video).to.be.null
461
462 expect(abuse.comment.deleted).to.be.false
463 expect(abuse.comment.id).to.equal(comment.id)
464 expect(abuse.comment.text).to.equal(comment.text)
465 expect(abuse.comment.video.name).to.equal('server 1')
466 expect(abuse.comment.video.id).to.equal(servers[0].video.id)
467 expect(abuse.comment.video.uuid).to.equal(servers[0].video.uuid)
468
469 expect(abuse.countReportsForReporter).to.equal(5)
470 expect(abuse.countReportsForReportee).to.equal(5)
471 }
472
473 {
474 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
475 expect(res.body.total).to.equal(0)
476 expect(res.body.data.length).to.equal(0)
477 }
478 })
479
480 it('Should report abuse on a remote comment', async function () {
481 this.timeout(10000)
482
483 const comment = await getComment(servers[0].url, servers[1].video.uuid)
484
485 const reason = 'it is a really bad comment'
486 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason })
487
488 await waitJobs(servers)
489 })
490
491 it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () {
492 const commentServer2 = await getComment(servers[0].url, servers[1].video.id)
493
494 const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
495 expect(res1.body.total).to.equal(2)
496 expect(res1.body.data.length).to.equal(2)
497
498 const abuse: Abuse = res1.body.data[0]
499 expect(abuse.reason).to.equal('it is a bad comment')
500 expect(abuse.countReportsForReporter).to.equal(6)
501 expect(abuse.countReportsForReportee).to.equal(5)
502
503 const abuse2: Abuse = res1.body.data[1]
504
505 expect(abuse2.reason).to.equal('it is a really bad comment')
506
507 expect(abuse2.reporterAccount.name).to.equal('root')
508 expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
509
510 expect(abuse2.video).to.be.null
511
512 expect(abuse2.comment.deleted).to.be.false
513 expect(abuse2.comment.id).to.equal(commentServer2.id)
514 expect(abuse2.comment.text).to.equal(commentServer2.text)
515 expect(abuse2.comment.video.name).to.equal('server 2')
516 expect(abuse2.comment.video.uuid).to.equal(servers[1].video.uuid)
517
518 expect(abuse2.state.id).to.equal(AbuseState.PENDING)
519 expect(abuse2.state.label).to.equal('Pending')
520
521 expect(abuse2.moderationComment).to.be.null
522
523 expect(abuse2.countReportsForReporter).to.equal(6)
524 expect(abuse2.countReportsForReportee).to.equal(2)
525
526 const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
527 expect(res2.body.total).to.equal(1)
528 expect(res2.body.data.length).to.equal(1)
529
530 abuseServer2 = res2.body.data[0]
531 expect(abuseServer2.reason).to.equal('it is a really bad comment')
532 expect(abuseServer2.reporterAccount.name).to.equal('root')
533 expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
534
535 expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
536 expect(abuseServer2.state.label).to.equal('Pending')
537
538 expect(abuseServer2.moderationComment).to.be.null
539
540 expect(abuseServer2.countReportsForReporter).to.equal(1)
541 expect(abuseServer2.countReportsForReportee).to.equal(1)
542 })
543
544 it('Should keep the comment abuse when deleting the comment', async function () {
545 this.timeout(10000)
546
547 const commentServer2 = await getComment(servers[0].url, servers[1].video.id)
548
549 await deleteVideoComment(servers[0].url, servers[0].accessToken, servers[1].video.uuid, commentServer2.id)
550
551 await waitJobs(servers)
552
553 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
554 expect(res.body.total).to.equal(2)
555 expect(res.body.data).to.have.lengthOf(2)
556
557 const abuse = (res.body.data as Abuse[]).find(a => a.comment?.id === commentServer2.id)
558 expect(abuse).to.not.be.undefined
559
560 expect(abuse.comment.text).to.be.empty
561 expect(abuse.comment.video.name).to.equal('server 2')
562 expect(abuse.comment.deleted).to.be.true
563 })
564
565 it('Should delete the comment abuse', async function () {
566 this.timeout(10000)
567
568 await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
569
570 await waitJobs(servers)
571
572 {
573 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
574 expect(res.body.total).to.equal(0)
575 expect(res.body.data.length).to.equal(0)
576 }
577
578 {
579 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
580 expect(res.body.total).to.equal(2)
581 }
582 })
583
584 it('Should list and filter video abuses', async function () {
585 {
586 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment', searchReportee: 'foo' })
587 expect(res.body.total).to.equal(0)
588 }
589
590 {
591 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment', searchReportee: 'ot' })
592 expect(res.body.total).to.equal(2)
593 }
594
595 {
596 const baseParams = { url: servers[0].url, token: servers[0].accessToken, filter: 'comment' as AbuseFilter, start: 1, count: 1 }
597
598 const res1 = await getAbusesList(immutableAssign(baseParams, { sort: 'createdAt' }))
599 expect(res1.body.data).to.have.lengthOf(1)
600 expect(res1.body.data[0].comment.text).to.be.empty
601
602 const res2 = await getAbusesList(immutableAssign(baseParams, { sort: '-createdAt' }))
603 expect(res2.body.data).to.have.lengthOf(1)
604 expect(res2.body.data[0].comment.text).to.equal('comment server 1')
605 }
606 })
607 })
608
609 describe('Account abuses', function () {
610
611 async function getAccountFromServer (url: string, name: string, server: ServerInfo) {
612 const res = await getAccount(url, name + '@' + server.host)
613
614 return res.body as Account
615 }
616
617 before(async function () {
618 this.timeout(50000)
619
620 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: 'user_1', password: 'donald' })
621
622 const token = await generateUserAccessToken(servers[1], 'user_2')
623 await uploadVideo(servers[1].url, token, { name: 'super video' })
624
625 await waitJobs(servers)
626 })
627
628 it('Should report abuse on an account', async function () {
629 this.timeout(15000)
630
631 const account = await getAccountFromServer(servers[0].url, 'user_1', servers[0])
632
633 const reason = 'it is a bad account'
634 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId: account.id, reason })
635
636 await waitJobs(servers)
637 })
638
639 it('Should have 1 account abuse on server 1 and 0 on server 2', async function () {
640 {
641 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
642
643 expect(res.body.total).to.equal(1)
644 expect(res.body.data).to.have.lengthOf(1)
645
646 const abuse: Abuse = res.body.data[0]
647 expect(abuse.reason).to.equal('it is a bad account')
648
649 expect(abuse.reporterAccount.name).to.equal('root')
650 expect(abuse.reporterAccount.host).to.equal(servers[0].host)
651
652 expect(abuse.video).to.be.null
653 expect(abuse.comment).to.be.null
654
655 expect(abuse.flaggedAccount.name).to.equal('user_1')
656 expect(abuse.flaggedAccount.host).to.equal(servers[0].host)
657 }
658
659 {
660 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
661 expect(res.body.total).to.equal(0)
662 expect(res.body.data.length).to.equal(0)
663 }
664 })
665
666 it('Should report abuse on a remote account', async function () {
667 this.timeout(10000)
668
669 const account = await getAccountFromServer(servers[0].url, 'user_2', servers[1])
670
671 const reason = 'it is a really bad account'
672 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId: account.id, reason })
673
674 await waitJobs(servers)
675 })
676
677 it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () {
678 const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
679 expect(res1.body.total).to.equal(2)
680 expect(res1.body.data.length).to.equal(2)
681
682 const abuse: Abuse = res1.body.data[0]
683 expect(abuse.reason).to.equal('it is a bad account')
684
685 const abuse2: Abuse = res1.body.data[1]
686 expect(abuse2.reason).to.equal('it is a really bad account')
687
688 expect(abuse2.reporterAccount.name).to.equal('root')
689 expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
690
691 expect(abuse2.video).to.be.null
692 expect(abuse2.comment).to.be.null
693
694 expect(abuse2.state.id).to.equal(AbuseState.PENDING)
695 expect(abuse2.state.label).to.equal('Pending')
696
697 expect(abuse2.moderationComment).to.be.null
698
699 const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'account' })
700 expect(res2.body.total).to.equal(1)
701 expect(res2.body.data.length).to.equal(1)
702
703 abuseServer2 = res2.body.data[0]
704
705 expect(abuseServer2.reason).to.equal('it is a really bad account')
706
707 expect(abuseServer2.reporterAccount.name).to.equal('root')
708 expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
709
710 expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
711 expect(abuseServer2.state.label).to.equal('Pending')
712
713 expect(abuseServer2.moderationComment).to.be.null
714 })
715
716 it('Should keep the account abuse when deleting the account', async function () {
717 this.timeout(10000)
718
719 const account = await getAccountFromServer(servers[1].url, 'user_2', servers[1])
720 await removeUser(servers[1].url, account.userId, servers[1].accessToken)
721
722 await waitJobs(servers)
723
724 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
725 expect(res.body.total).to.equal(2)
726 expect(res.body.data).to.have.lengthOf(2)
727
728 const abuse = (res.body.data as Abuse[]).find(a => a.reason === 'it is a really bad account')
729 expect(abuse).to.not.be.undefined
730 })
731
732 it('Should delete the account abuse', async function () {
733 this.timeout(10000)
734
735 await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
736
737 await waitJobs(servers)
738
739 {
740 const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'account' })
741 expect(res.body.total).to.equal(0)
742 expect(res.body.data.length).to.equal(0)
743 }
744
745 {
746 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
747 expect(res.body.total).to.equal(2)
748
749 abuseServer1 = res.body.data[0]
750 }
751 })
752 })
753
754 describe('Common actions on abuses', function () {
755
756 it('Should update the state of an abuse', async function () {
757 const body = { state: AbuseState.REJECTED }
758 await updateAbuse(servers[0].url, servers[0].accessToken, abuseServer1.id, body)
759
760 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, id: abuseServer1.id })
761 expect(res.body.data[0].state.id).to.equal(AbuseState.REJECTED)
762 })
763
764 it('Should add a moderation comment', async function () {
765 const body = { state: AbuseState.ACCEPTED, moderationComment: 'It is valid' }
766 await updateAbuse(servers[0].url, servers[0].accessToken, abuseServer1.id, body)
767
768 const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, id: abuseServer1.id })
769 expect(res.body.data[0].state.id).to.equal(AbuseState.ACCEPTED)
770 expect(res.body.data[0].moderationComment).to.equal('It is valid')
771 })
772 })
773
774 after(async function () {
775 await cleanupTests(servers)
776 })
777})
diff --git a/server/tests/api/users/blocklist.ts b/server/tests/api/moderation/blocklist.ts
index 8c9107a50..8c9107a50 100644
--- a/server/tests/api/users/blocklist.ts
+++ b/server/tests/api/moderation/blocklist.ts
diff --git a/server/tests/api/moderation/index.ts b/server/tests/api/moderation/index.ts
new file mode 100644
index 000000000..cb018d88e
--- /dev/null
+++ b/server/tests/api/moderation/index.ts
@@ -0,0 +1,2 @@
1export * from './abuses'
2export * from './blocklist'
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index b90732a7a..a8517600a 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -3,15 +3,21 @@
3import 'mocha' 3import 'mocha'
4import { v4 as uuidv4 } from 'uuid' 4import { v4 as uuidv4 } from 'uuid'
5import { 5import {
6 addVideoCommentThread,
6 addVideoToBlacklist, 7 addVideoToBlacklist,
7 cleanupTests, 8 cleanupTests,
9 createUser,
8 follow, 10 follow,
11 generateUserAccessToken,
12 getAccount,
9 getCustomConfig, 13 getCustomConfig,
14 getVideoCommentThreads,
15 getVideoIdFromUUID,
10 immutableAssign, 16 immutableAssign,
11 MockInstancesIndex, 17 MockInstancesIndex,
12 registerUser, 18 registerUser,
13 removeVideoFromBlacklist, 19 removeVideoFromBlacklist,
14 reportVideoAbuse, 20 reportAbuse,
15 unfollow, 21 unfollow,
16 updateCustomConfig, 22 updateCustomConfig,
17 updateCustomSubConfig, 23 updateCustomSubConfig,
@@ -23,7 +29,9 @@ import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
23import { 29import {
24 checkAutoInstanceFollowing, 30 checkAutoInstanceFollowing,
25 CheckerBaseParams, 31 CheckerBaseParams,
32 checkNewAccountAbuseForModerators,
26 checkNewBlacklistOnMyVideo, 33 checkNewBlacklistOnMyVideo,
34 checkNewCommentAbuseForModerators,
27 checkNewInstanceFollower, 35 checkNewInstanceFollower,
28 checkNewVideoAbuseForModerators, 36 checkNewVideoAbuseForModerators,
29 checkNewVideoFromSubscription, 37 checkNewVideoFromSubscription,
@@ -74,12 +82,12 @@ describe('Test moderation notifications', function () {
74 82
75 const name = 'video for abuse ' + uuidv4() 83 const name = 'video for abuse ' + uuidv4()
76 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 84 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
77 const uuid = resVideo.body.video.uuid 85 const video = resVideo.body.video
78 86
79 await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason') 87 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: video.id, reason: 'super reason' })
80 88
81 await waitJobs(servers) 89 await waitJobs(servers)
82 await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence') 90 await checkNewVideoAbuseForModerators(baseParams, video.uuid, name, 'presence')
83 }) 91 })
84 92
85 it('Should send a notification to moderators on remote video abuse', async function () { 93 it('Should send a notification to moderators on remote video abuse', async function () {
@@ -87,14 +95,77 @@ describe('Test moderation notifications', function () {
87 95
88 const name = 'video for abuse ' + uuidv4() 96 const name = 'video for abuse ' + uuidv4()
89 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 97 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
90 const uuid = resVideo.body.video.uuid 98 const video = resVideo.body.video
99
100 await waitJobs(servers)
101
102 const videoId = await getVideoIdFromUUID(servers[1].url, video.uuid)
103 await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, videoId, reason: 'super reason' })
104
105 await waitJobs(servers)
106 await checkNewVideoAbuseForModerators(baseParams, video.uuid, name, 'presence')
107 })
108
109 it('Should send a notification to moderators on local comment abuse', async function () {
110 this.timeout(10000)
111
112 const name = 'video for abuse ' + uuidv4()
113 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
114 const video = resVideo.body.video
115 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4())
116 const comment = resComment.body.comment
117
118 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason: 'super reason' })
119
120 await waitJobs(servers)
121 await checkNewCommentAbuseForModerators(baseParams, video.uuid, name, 'presence')
122 })
123
124 it('Should send a notification to moderators on remote comment abuse', async function () {
125 this.timeout(10000)
126
127 const name = 'video for abuse ' + uuidv4()
128 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
129 const video = resVideo.body.video
130 await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4())
131
132 await waitJobs(servers)
133
134 const resComments = await getVideoCommentThreads(servers[1].url, video.uuid, 0, 5)
135 const commentId = resComments.body.data[0].id
136 await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, commentId, reason: 'super reason' })
137
138 await waitJobs(servers)
139 await checkNewCommentAbuseForModerators(baseParams, video.uuid, name, 'presence')
140 })
141
142 it('Should send a notification to moderators on local account abuse', async function () {
143 this.timeout(10000)
144
145 const username = 'user' + new Date().getTime()
146 const resUser = await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username, password: 'donald' })
147 const accountId = resUser.body.user.account.id
148
149 await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId, reason: 'super reason' })
150
151 await waitJobs(servers)
152 await checkNewAccountAbuseForModerators(baseParams, username, 'presence')
153 })
154
155 it('Should send a notification to moderators on remote account abuse', async function () {
156 this.timeout(10000)
157
158 const username = 'user' + new Date().getTime()
159 const tmpToken = await generateUserAccessToken(servers[0], username)
160 await uploadVideo(servers[0].url, tmpToken, { name: 'super video' })
91 161
92 await waitJobs(servers) 162 await waitJobs(servers)
93 163
94 await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason') 164 const resAccount = await getAccount(servers[1].url, username + '@' + servers[0].host)
165 await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, accountId: resAccount.body.id, reason: 'super reason' })
95 166
96 await waitJobs(servers) 167 await waitJobs(servers)
97 await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence') 168 await checkNewAccountAbuseForModerators(baseParams, username, 'presence')
98 }) 169 })
99 }) 170 })
100 171
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 95b64a459..b01a91d48 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
4import * as chai from 'chai'
5import { 5import {
6 addVideoToBlacklist, 6 addVideoToBlacklist,
7 askResetPassword, 7 askResetPassword,
@@ -11,7 +11,7 @@ import {
11 createUser, 11 createUser,
12 flushAndRunServer, 12 flushAndRunServer,
13 removeVideoFromBlacklist, 13 removeVideoFromBlacklist,
14 reportVideoAbuse, 14 reportAbuse,
15 resetPassword, 15 resetPassword,
16 ServerInfo, 16 ServerInfo,
17 setAccessTokensToServers, 17 setAccessTokensToServers,
@@ -30,10 +30,15 @@ describe('Test emails', function () {
30 let userId: number 30 let userId: number
31 let userId2: number 31 let userId2: number
32 let userAccessToken: string 32 let userAccessToken: string
33
33 let videoUUID: string 34 let videoUUID: string
35 let videoId: number
36
34 let videoUserUUID: string 37 let videoUserUUID: string
38
35 let verificationString: string 39 let verificationString: string
36 let verificationString2: string 40 let verificationString2: string
41
37 const emails: object[] = [] 42 const emails: object[] = []
38 const user = { 43 const user = {
39 username: 'user_1', 44 username: 'user_1',
@@ -76,6 +81,7 @@ describe('Test emails', function () {
76 } 81 }
77 const res = await uploadVideo(server.url, server.accessToken, attributes) 82 const res = await uploadVideo(server.url, server.accessToken, attributes)
78 videoUUID = res.body.video.uuid 83 videoUUID = res.body.video.uuid
84 videoId = res.body.video.id
79 } 85 }
80 }) 86 })
81 87
@@ -174,12 +180,12 @@ describe('Test emails', function () {
174 }) 180 })
175 }) 181 })
176 182
177 describe('When creating a video abuse', function () { 183 describe('When creating an abuse', function () {
178 it('Should send the notification email', async function () { 184 it('Should send the notification email', async function () {
179 this.timeout(10000) 185 this.timeout(10000)
180 186
181 const reason = 'my super bad reason' 187 const reason = 'my super bad reason'
182 await reportVideoAbuse(server.url, server.accessToken, videoUUID, reason) 188 await reportAbuse({ url: server.url, token: server.accessToken, videoId, reason })
183 189
184 await waitJobs(server) 190 await waitJobs(server)
185 expect(emails).to.have.lengthOf(3) 191 expect(emails).to.have.lengthOf(3)
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index fcd022429..a244a6edb 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,5 +1,4 @@
1import './users-verification'
2import './blocklist'
3import './user-subscriptions' 1import './user-subscriptions'
4import './users' 2import './users'
5import './users-multiple-servers' 3import './users-multiple-servers'
4import './users-verification'
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 0a66bd1ce..ea74bde6a 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -1,8 +1,9 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { MyUser, User, UserRole, Video, VideoAbuseState, VideoAbuseUpdate, VideoPlaylistType } from '../../../../shared/index' 4import * as chai from 'chai'
5import { AbuseState, AbuseUpdate, MyUser, User, UserRole, Video, VideoPlaylistType } from '@shared/models'
6import { CustomConfig } from '@shared/models/server'
6import { 7import {
7 addVideoCommentThread, 8 addVideoCommentThread,
8 blockUser, 9 blockUser,
@@ -10,6 +11,7 @@ import {
10 createUser, 11 createUser,
11 deleteMe, 12 deleteMe,
12 flushAndRunServer, 13 flushAndRunServer,
14 getAbusesList,
13 getAccountRatings, 15 getAccountRatings,
14 getBlacklistedVideosList, 16 getBlacklistedVideosList,
15 getCustomConfig, 17 getCustomConfig,
@@ -19,7 +21,6 @@ import {
19 getUserInformation, 21 getUserInformation,
20 getUsersList, 22 getUsersList,
21 getUsersListPaginationAndSort, 23 getUsersListPaginationAndSort,
22 getVideoAbusesList,
23 getVideoChannel, 24 getVideoChannel,
24 getVideosList, 25 getVideosList,
25 installPlugin, 26 installPlugin,
@@ -29,15 +30,15 @@ import {
29 registerUserWithChannel, 30 registerUserWithChannel,
30 removeUser, 31 removeUser,
31 removeVideo, 32 removeVideo,
32 reportVideoAbuse, 33 reportAbuse,
33 ServerInfo, 34 ServerInfo,
34 testImage, 35 testImage,
35 unblockUser, 36 unblockUser,
37 updateAbuse,
36 updateCustomSubConfig, 38 updateCustomSubConfig,
37 updateMyAvatar, 39 updateMyAvatar,
38 updateMyUser, 40 updateMyUser,
39 updateUser, 41 updateUser,
40 updateVideoAbuse,
41 uploadVideo, 42 uploadVideo,
42 userLogin, 43 userLogin,
43 waitJobs 44 waitJobs
@@ -46,7 +47,6 @@ import { follow } from '../../../../shared/extra-utils/server/follows'
46import { logout, serverLogin, setAccessTokensToServers } from '../../../../shared/extra-utils/users/login' 47import { logout, serverLogin, setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
47import { getMyVideos } from '../../../../shared/extra-utils/videos/videos' 48import { getMyVideos } from '../../../../shared/extra-utils/videos/videos'
48import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model' 49import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model'
49import { CustomConfig } from '@shared/models/server'
50 50
51const expect = chai.expect 51const expect = chai.expect
52 52
@@ -302,10 +302,10 @@ describe('Test users', function () {
302 expect(userGet.videosCount).to.equal(0) 302 expect(userGet.videosCount).to.equal(0)
303 expect(userGet.videoCommentsCount).to.be.a('number') 303 expect(userGet.videoCommentsCount).to.be.a('number')
304 expect(userGet.videoCommentsCount).to.equal(0) 304 expect(userGet.videoCommentsCount).to.equal(0)
305 expect(userGet.videoAbusesCount).to.be.a('number') 305 expect(userGet.abusesCount).to.be.a('number')
306 expect(userGet.videoAbusesCount).to.equal(0) 306 expect(userGet.abusesCount).to.equal(0)
307 expect(userGet.videoAbusesAcceptedCount).to.be.a('number') 307 expect(userGet.abusesAcceptedCount).to.be.a('number')
308 expect(userGet.videoAbusesAcceptedCount).to.equal(0) 308 expect(userGet.abusesAcceptedCount).to.equal(0)
309 }) 309 })
310 }) 310 })
311 311
@@ -895,9 +895,9 @@ describe('Test users', function () {
895 895
896 expect(user.videosCount).to.equal(0) 896 expect(user.videosCount).to.equal(0)
897 expect(user.videoCommentsCount).to.equal(0) 897 expect(user.videoCommentsCount).to.equal(0)
898 expect(user.videoAbusesCount).to.equal(0) 898 expect(user.abusesCount).to.equal(0)
899 expect(user.videoAbusesCreatedCount).to.equal(0) 899 expect(user.abusesCreatedCount).to.equal(0)
900 expect(user.videoAbusesAcceptedCount).to.equal(0) 900 expect(user.abusesAcceptedCount).to.equal(0)
901 }) 901 })
902 902
903 it('Should report correct videos count', async function () { 903 it('Should report correct videos count', async function () {
@@ -924,26 +924,26 @@ describe('Test users', function () {
924 expect(user.videoCommentsCount).to.equal(1) 924 expect(user.videoCommentsCount).to.equal(1)
925 }) 925 })
926 926
927 it('Should report correct video abuses counts', async function () { 927 it('Should report correct abuses counts', async function () {
928 const reason = 'my super bad reason' 928 const reason = 'my super bad reason'
929 await reportVideoAbuse(server.url, user17AccessToken, videoId, reason) 929 await reportAbuse({ url: server.url, token: user17AccessToken, videoId, reason })
930 930
931 const res1 = await getVideoAbusesList({ url: server.url, token: server.accessToken }) 931 const res1 = await getAbusesList({ url: server.url, token: server.accessToken })
932 const abuseId = res1.body.data[0].id 932 const abuseId = res1.body.data[0].id
933 933
934 const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true) 934 const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
935 const user2: User = res2.body 935 const user2: User = res2.body
936 936
937 expect(user2.videoAbusesCount).to.equal(1) // number of incriminations 937 expect(user2.abusesCount).to.equal(1) // number of incriminations
938 expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created 938 expect(user2.abusesCreatedCount).to.equal(1) // number of reports created
939 939
940 const body: VideoAbuseUpdate = { state: VideoAbuseState.ACCEPTED } 940 const body: AbuseUpdate = { state: AbuseState.ACCEPTED }
941 await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body) 941 await updateAbuse(server.url, server.accessToken, abuseId, body)
942 942
943 const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true) 943 const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true)
944 const user3: User = res3.body 944 const user3: User = res3.body
945 945
946 expect(user3.videoAbusesAcceptedCount).to.equal(1) // number of reports created accepted 946 expect(user3.abusesAcceptedCount).to.equal(1) // number of reports created accepted
947 }) 947 })
948 }) 948 })
949 949
diff --git a/server/tests/api/videos/video-abuse.ts b/server/tests/api/videos/video-abuse.ts
index 7383bd991..baeb543e0 100644
--- a/server/tests/api/videos/video-abuse.ts
+++ b/server/tests/api/videos/video-abuse.ts
@@ -1,21 +1,21 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos' 4import * as chai from 'chai'
5import { Abuse, AbusePredefinedReasonsString, AbuseState } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createUser,
8 deleteVideoAbuse, 9 deleteVideoAbuse,
9 flushAndRunMultipleServers, 10 flushAndRunMultipleServers,
10 getVideoAbusesList, 11 getVideoAbusesList,
11 getVideosList, 12 getVideosList,
13 removeVideo,
12 reportVideoAbuse, 14 reportVideoAbuse,
13 ServerInfo, 15 ServerInfo,
14 setAccessTokensToServers, 16 setAccessTokensToServers,
15 updateVideoAbuse, 17 updateVideoAbuse,
16 uploadVideo, 18 uploadVideo,
17 removeVideo,
18 createUser,
19 userLogin 19 userLogin
20} from '../../../../shared/extra-utils/index' 20} from '../../../../shared/extra-utils/index'
21import { doubleFollow } from '../../../../shared/extra-utils/server/follows' 21import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
@@ -29,9 +29,11 @@ import {
29 29
30const expect = chai.expect 30const expect = chai.expect
31 31
32// FIXME: deprecated in 2.3. Remove this controller
33
32describe('Test video abuses', function () { 34describe('Test video abuses', function () {
33 let servers: ServerInfo[] = [] 35 let servers: ServerInfo[] = []
34 let abuseServer2: VideoAbuse 36 let abuseServer2: Abuse
35 37
36 before(async function () { 38 before(async function () {
37 this.timeout(50000) 39 this.timeout(50000)
@@ -95,14 +97,14 @@ describe('Test video abuses', function () {
95 expect(res1.body.data).to.be.an('array') 97 expect(res1.body.data).to.be.an('array')
96 expect(res1.body.data.length).to.equal(1) 98 expect(res1.body.data.length).to.equal(1)
97 99
98 const abuse: VideoAbuse = res1.body.data[0] 100 const abuse: Abuse = res1.body.data[0]
99 expect(abuse.reason).to.equal('my super bad reason') 101 expect(abuse.reason).to.equal('my super bad reason')
100 expect(abuse.reporterAccount.name).to.equal('root') 102 expect(abuse.reporterAccount.name).to.equal('root')
101 expect(abuse.reporterAccount.host).to.equal('localhost:' + servers[0].port) 103 expect(abuse.reporterAccount.host).to.equal('localhost:' + servers[0].port)
102 expect(abuse.video.id).to.equal(servers[0].video.id) 104 expect(abuse.video.id).to.equal(servers[0].video.id)
103 expect(abuse.video.channel).to.exist 105 expect(abuse.video.channel).to.exist
104 expect(abuse.count).to.equal(1) 106 expect(abuse.video.countReports).to.equal(1)
105 expect(abuse.nth).to.equal(1) 107 expect(abuse.video.nthReport).to.equal(1)
106 expect(abuse.countReportsForReporter).to.equal(1) 108 expect(abuse.countReportsForReporter).to.equal(1)
107 expect(abuse.countReportsForReportee).to.equal(1) 109 expect(abuse.countReportsForReportee).to.equal(1)
108 110
@@ -128,23 +130,23 @@ describe('Test video abuses', function () {
128 expect(res1.body.data).to.be.an('array') 130 expect(res1.body.data).to.be.an('array')
129 expect(res1.body.data.length).to.equal(2) 131 expect(res1.body.data.length).to.equal(2)
130 132
131 const abuse1: VideoAbuse = res1.body.data[0] 133 const abuse1: Abuse = res1.body.data[0]
132 expect(abuse1.reason).to.equal('my super bad reason') 134 expect(abuse1.reason).to.equal('my super bad reason')
133 expect(abuse1.reporterAccount.name).to.equal('root') 135 expect(abuse1.reporterAccount.name).to.equal('root')
134 expect(abuse1.reporterAccount.host).to.equal('localhost:' + servers[0].port) 136 expect(abuse1.reporterAccount.host).to.equal('localhost:' + servers[0].port)
135 expect(abuse1.video.id).to.equal(servers[0].video.id) 137 expect(abuse1.video.id).to.equal(servers[0].video.id)
136 expect(abuse1.state.id).to.equal(VideoAbuseState.PENDING) 138 expect(abuse1.state.id).to.equal(AbuseState.PENDING)
137 expect(abuse1.state.label).to.equal('Pending') 139 expect(abuse1.state.label).to.equal('Pending')
138 expect(abuse1.moderationComment).to.be.null 140 expect(abuse1.moderationComment).to.be.null
139 expect(abuse1.count).to.equal(1) 141 expect(abuse1.video.countReports).to.equal(1)
140 expect(abuse1.nth).to.equal(1) 142 expect(abuse1.video.nthReport).to.equal(1)
141 143
142 const abuse2: VideoAbuse = res1.body.data[1] 144 const abuse2: Abuse = res1.body.data[1]
143 expect(abuse2.reason).to.equal('my super bad reason 2') 145 expect(abuse2.reason).to.equal('my super bad reason 2')
144 expect(abuse2.reporterAccount.name).to.equal('root') 146 expect(abuse2.reporterAccount.name).to.equal('root')
145 expect(abuse2.reporterAccount.host).to.equal('localhost:' + servers[0].port) 147 expect(abuse2.reporterAccount.host).to.equal('localhost:' + servers[0].port)
146 expect(abuse2.video.id).to.equal(servers[1].video.id) 148 expect(abuse2.video.id).to.equal(servers[1].video.id)
147 expect(abuse2.state.id).to.equal(VideoAbuseState.PENDING) 149 expect(abuse2.state.id).to.equal(AbuseState.PENDING)
148 expect(abuse2.state.label).to.equal('Pending') 150 expect(abuse2.state.label).to.equal('Pending')
149 expect(abuse2.moderationComment).to.be.null 151 expect(abuse2.moderationComment).to.be.null
150 152
@@ -157,25 +159,25 @@ describe('Test video abuses', function () {
157 expect(abuseServer2.reason).to.equal('my super bad reason 2') 159 expect(abuseServer2.reason).to.equal('my super bad reason 2')
158 expect(abuseServer2.reporterAccount.name).to.equal('root') 160 expect(abuseServer2.reporterAccount.name).to.equal('root')
159 expect(abuseServer2.reporterAccount.host).to.equal('localhost:' + servers[0].port) 161 expect(abuseServer2.reporterAccount.host).to.equal('localhost:' + servers[0].port)
160 expect(abuseServer2.state.id).to.equal(VideoAbuseState.PENDING) 162 expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
161 expect(abuseServer2.state.label).to.equal('Pending') 163 expect(abuseServer2.state.label).to.equal('Pending')
162 expect(abuseServer2.moderationComment).to.be.null 164 expect(abuseServer2.moderationComment).to.be.null
163 }) 165 })
164 166
165 it('Should update the state of a video abuse', async function () { 167 it('Should update the state of a video abuse', async function () {
166 const body = { state: VideoAbuseState.REJECTED } 168 const body = { state: AbuseState.REJECTED }
167 await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body) 169 await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body)
168 170
169 const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken }) 171 const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken })
170 expect(res.body.data[0].state.id).to.equal(VideoAbuseState.REJECTED) 172 expect(res.body.data[0].state.id).to.equal(AbuseState.REJECTED)
171 }) 173 })
172 174
173 it('Should add a moderation comment', async function () { 175 it('Should add a moderation comment', async function () {
174 const body = { state: VideoAbuseState.ACCEPTED, moderationComment: 'It is valid' } 176 const body = { state: AbuseState.ACCEPTED, moderationComment: 'It is valid' }
175 await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body) 177 await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body)
176 178
177 const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken }) 179 const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken })
178 expect(res.body.data[0].state.id).to.equal(VideoAbuseState.ACCEPTED) 180 expect(res.body.data[0].state.id).to.equal(AbuseState.ACCEPTED)
179 expect(res.body.data[0].moderationComment).to.equal('It is valid') 181 expect(res.body.data[0].moderationComment).to.equal('It is valid')
180 }) 182 })
181 183
@@ -243,7 +245,7 @@ describe('Test video abuses', function () {
243 expect(res.body.data.length).to.equal(2, "wrong number of videos returned") 245 expect(res.body.data.length).to.equal(2, "wrong number of videos returned")
244 expect(res.body.data[0].id).to.equal(abuseServer2.id, "wrong origin server id for first video") 246 expect(res.body.data[0].id).to.equal(abuseServer2.id, "wrong origin server id for first video")
245 247
246 const abuse: VideoAbuse = res.body.data[0] 248 const abuse: Abuse = res.body.data[0]
247 expect(abuse.video.id).to.equal(abuseServer2.video.id, "wrong video id") 249 expect(abuse.video.id).to.equal(abuseServer2.video.id, "wrong video id")
248 expect(abuse.video.channel).to.exist 250 expect(abuse.video.channel).to.exist
249 expect(abuse.video.deleted).to.be.true 251 expect(abuse.video.deleted).to.be.true
@@ -277,10 +279,10 @@ describe('Test video abuses', function () {
277 const res2 = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) 279 const res2 = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
278 280
279 { 281 {
280 for (const abuse of res2.body.data as VideoAbuse[]) { 282 for (const abuse of res2.body.data as Abuse[]) {
281 if (abuse.video.id === video3.id) { 283 if (abuse.video.id === video3.id) {
282 expect(abuse.count).to.equal(1, "wrong reports count for video 3") 284 expect(abuse.video.countReports).to.equal(1, "wrong reports count for video 3")
283 expect(abuse.nth).to.equal(1, "wrong report position in report list for video 3") 285 expect(abuse.video.nthReport).to.equal(1, "wrong report position in report list for video 3")
284 expect(abuse.countReportsForReportee).to.equal(1, "wrong reports count for reporter on video 3 abuse") 286 expect(abuse.countReportsForReportee).to.equal(1, "wrong reports count for reporter on video 3 abuse")
285 expect(abuse.countReportsForReporter).to.equal(3, "wrong reports count for reportee on video 3 abuse") 287 expect(abuse.countReportsForReporter).to.equal(3, "wrong reports count for reportee on video 3 abuse")
286 } 288 }
@@ -295,7 +297,7 @@ describe('Test video abuses', function () {
295 this.timeout(10000) 297 this.timeout(10000)
296 298
297 const reason5 = 'my super bad reason 5' 299 const reason5 = 'my super bad reason 5'
298 const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ] 300 const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
299 const createdAbuse = (await reportVideoAbuse( 301 const createdAbuse = (await reportVideoAbuse(
300 servers[0].url, 302 servers[0].url,
301 servers[0].accessToken, 303 servers[0].accessToken,
@@ -304,16 +306,16 @@ describe('Test video abuses', function () {
304 predefinedReasons5, 306 predefinedReasons5,
305 1, 307 1,
306 5 308 5
307 )).body.videoAbuse as VideoAbuse 309 )).body.abuse
308 310
309 const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) 311 const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
310 312
311 { 313 {
312 const abuse = (res.body.data as VideoAbuse[]).find(a => a.id === createdAbuse.id) 314 const abuse = (res.body.data as Abuse[]).find(a => a.id === createdAbuse.id)
313 expect(abuse.reason).to.equals(reason5) 315 expect(abuse.reason).to.equals(reason5)
314 expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported") 316 expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported")
315 expect(abuse.startAt).to.equal(1, "starting timestamp doesn't match the one reported") 317 expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
316 expect(abuse.endAt).to.equal(5, "ending timestamp doesn't match the one reported") 318 expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
317 } 319 }
318 }) 320 })
319 321
@@ -348,7 +350,7 @@ describe('Test video abuses', function () {
348 350
349 const res = await getVideoAbusesList(options) 351 const res = await getVideoAbusesList(options)
350 352
351 return res.body.data as VideoAbuse[] 353 return res.body.data as Abuse[]
352 } 354 }
353 355
354 expect(await list({ id: 56 })).to.have.lengthOf(0) 356 expect(await list({ id: 56 })).to.have.lengthOf(0)
@@ -365,14 +367,14 @@ describe('Test video abuses', function () {
365 expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) 367 expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
366 expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5) 368 expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5)
367 369
368 expect(await list({ searchReportee: 'root' })).to.have.lengthOf(4) 370 expect(await list({ searchReportee: 'root' })).to.have.lengthOf(5)
369 expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0) 371 expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
370 372
371 expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) 373 expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
372 expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) 374 expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
373 375
374 expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0) 376 expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0)
375 expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6) 377 expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6)
376 378
377 expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1) 379 expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
378 expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0) 380 expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)
diff --git a/server/types/models/index.ts b/server/types/models/index.ts
index 78b4948ce..affa17425 100644
--- a/server/types/models/index.ts
+++ b/server/types/models/index.ts
@@ -1,4 +1,5 @@
1export * from './account' 1export * from './account'
2export * from './moderation'
2export * from './oauth' 3export * from './oauth'
3export * from './server' 4export * from './server'
4export * from './user' 5export * from './user'
diff --git a/server/types/models/moderation/abuse.ts b/server/types/models/moderation/abuse.ts
new file mode 100644
index 000000000..a0bf4b08f
--- /dev/null
+++ b/server/types/models/moderation/abuse.ts
@@ -0,0 +1,103 @@
1import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
2import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
3import { PickWith } from '@shared/core-utils'
4import { AbuseModel } from '../../../models/abuse/abuse'
5import { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl } from '../account'
6import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo, MComment, MCommentVideo } from '../video'
7import { MVideo, MVideoAccountLightBlacklistAllFiles } from '../video/video'
8
9type Use<K extends keyof AbuseModel, M> = PickWith<AbuseModel, K, M>
10type UseVideoAbuse<K extends keyof VideoAbuseModel, M> = PickWith<VideoAbuseModel, K, M>
11type UseCommentAbuse<K extends keyof VideoCommentAbuseModel, M> = PickWith<VideoCommentAbuseModel, K, M>
12
13// ############################################################################
14
15export type MAbuse = Omit<AbuseModel, 'VideoCommentAbuse' | 'VideoAbuse' | 'ReporterAccount' | 'FlaggedAccount' | 'toActivityPubObject'>
16
17export type MVideoAbuse = Omit<VideoAbuseModel, 'Abuse' | 'Video'>
18
19export type MCommentAbuse = Omit<VideoCommentAbuseModel, 'Abuse' | 'VideoComment'>
20
21// ############################################################################
22
23export type MVideoAbuseVideo =
24 MVideoAbuse &
25 UseVideoAbuse<'Video', MVideo>
26
27export type MVideoAbuseVideoUrl =
28 MVideoAbuse &
29 UseVideoAbuse<'Video', MVideoUrl>
30
31export type MVideoAbuseVideoFull =
32 MVideoAbuse &
33 UseVideoAbuse<'Video', MVideoAccountLightBlacklistAllFiles>
34
35export type MVideoAbuseFormattable =
36 MVideoAbuse &
37 UseVideoAbuse<'Video', Pick<MVideoAccountLightBlacklistAllFiles,
38 'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>>
39
40// ############################################################################
41
42export type MCommentAbuseAccount =
43 MCommentAbuse &
44 UseCommentAbuse<'VideoComment', MCommentOwner>
45
46export type MCommentAbuseAccountVideo =
47 MCommentAbuse &
48 UseCommentAbuse<'VideoComment', MCommentOwnerVideo>
49
50export type MCommentAbuseUrl =
51 MCommentAbuse &
52 UseCommentAbuse<'VideoComment', MCommentUrl>
53
54export type MCommentAbuseFormattable =
55 MCommentAbuse &
56 UseCommentAbuse<'VideoComment', MComment & PickWith<MCommentVideo, 'Video', Pick<MVideo, 'id' | 'uuid' | 'name'>>>
57
58// ############################################################################
59
60export type MAbuseId = Pick<AbuseModel, 'id'>
61
62export type MAbuseVideo =
63 MAbuse &
64 Pick<AbuseModel, 'toActivityPubObject'> &
65 Use<'VideoAbuse', MVideoAbuseVideo>
66
67export type MAbuseUrl =
68 MAbuse &
69 Use<'VideoAbuse', MVideoAbuseVideoUrl> &
70 Use<'VideoCommentAbuse', MCommentAbuseUrl>
71
72export type MAbuseAccountVideo =
73 MAbuse &
74 Pick<AbuseModel, 'toActivityPubObject'> &
75 Use<'VideoAbuse', MVideoAbuseVideoFull> &
76 Use<'ReporterAccount', MAccountDefault>
77
78export type MAbuseAP =
79 MAbuse &
80 Pick<AbuseModel, 'toActivityPubObject'> &
81 Use<'ReporterAccount', MAccountUrl> &
82 Use<'FlaggedAccount', MAccountUrl> &
83 Use<'VideoAbuse', MVideoAbuseVideo> &
84 Use<'VideoCommentAbuse', MCommentAbuseAccount>
85
86export type MAbuseFull =
87 MAbuse &
88 Pick<AbuseModel, 'toActivityPubObject'> &
89 Use<'ReporterAccount', MAccountLight> &
90 Use<'FlaggedAccount', MAccountLight> &
91 Use<'VideoAbuse', MVideoAbuseVideoFull> &
92 Use<'VideoCommentAbuse', MCommentAbuseAccountVideo>
93
94// ############################################################################
95
96// Format for API or AP object
97
98export type MAbuseFormattable =
99 MAbuse &
100 Use<'ReporterAccount', MAccountFormattable> &
101 Use<'FlaggedAccount', MAccountFormattable> &
102 Use<'VideoAbuse', MVideoAbuseFormattable> &
103 Use<'VideoCommentAbuse', MCommentAbuseFormattable>
diff --git a/server/types/models/moderation/index.ts b/server/types/models/moderation/index.ts
new file mode 100644
index 000000000..8bea1708f
--- /dev/null
+++ b/server/types/models/moderation/index.ts
@@ -0,0 +1 @@
export * from './abuse'
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index dd3de423b..f59eb7260 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -1,16 +1,18 @@
1import { UserNotificationModel } from '../../../models/account/user-notification' 1import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
2import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
2import { PickWith, PickWithOpt } from '@shared/core-utils' 3import { PickWith, PickWithOpt } from '@shared/core-utils'
3import { VideoModel } from '../../../models/video/video' 4import { AbuseModel } from '../../../models/abuse/abuse'
5import { AccountModel } from '../../../models/account/account'
6import { UserNotificationModel } from '../../../models/account/user-notification'
4import { ActorModel } from '../../../models/activitypub/actor' 7import { ActorModel } from '../../../models/activitypub/actor'
5import { ServerModel } from '../../../models/server/server' 8import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
6import { AvatarModel } from '../../../models/avatar/avatar' 9import { AvatarModel } from '../../../models/avatar/avatar'
10import { ServerModel } from '../../../models/server/server'
11import { VideoModel } from '../../../models/video/video'
12import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
7import { VideoChannelModel } from '../../../models/video/video-channel' 13import { VideoChannelModel } from '../../../models/video/video-channel'
8import { AccountModel } from '../../../models/account/account'
9import { VideoCommentModel } from '../../../models/video/video-comment' 14import { VideoCommentModel } from '../../../models/video/video-comment'
10import { VideoAbuseModel } from '../../../models/video/video-abuse'
11import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
12import { VideoImportModel } from '../../../models/video/video-import' 15import { VideoImportModel } from '../../../models/video/video-import'
13import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
14 16
15type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M> 17type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M>
16 18
@@ -47,6 +49,18 @@ export module UserNotificationIncludes {
47 Pick<VideoAbuseModel, 'id'> & 49 Pick<VideoAbuseModel, 'id'> &
48 PickWith<VideoAbuseModel, 'Video', VideoInclude> 50 PickWith<VideoAbuseModel, 'Video', VideoInclude>
49 51
52 export type VideoCommentAbuseInclude =
53 Pick<VideoCommentAbuseModel, 'id'> &
54 PickWith<VideoCommentAbuseModel, 'VideoComment',
55 Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId'> &
56 PickWith<VideoCommentModel, 'Video', Pick<VideoModel, 'id' | 'name' | 'uuid'>>>
57
58 export type AbuseInclude =
59 Pick<AbuseModel, 'id'> &
60 PickWith<AbuseModel, 'VideoAbuse', VideoAbuseInclude> &
61 PickWith<AbuseModel, 'VideoCommentAbuse', VideoCommentAbuseInclude> &
62 PickWith<AbuseModel, 'FlaggedAccount', AccountIncludeActor>
63
50 export type VideoBlacklistInclude = 64 export type VideoBlacklistInclude =
51 Pick<VideoBlacklistModel, 'id'> & 65 Pick<VideoBlacklistModel, 'id'> &
52 PickWith<VideoAbuseModel, 'Video', VideoInclude> 66 PickWith<VideoAbuseModel, 'Video', VideoInclude>
@@ -76,7 +90,7 @@ export module UserNotificationIncludes {
76// ############################################################################ 90// ############################################################################
77 91
78export type MUserNotification = 92export type MUserNotification =
79 Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'VideoAbuse' | 'VideoBlacklist' | 93 Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'Abuse' | 'VideoBlacklist' |
80 'VideoImport' | 'Account' | 'ActorFollow'> 94 'VideoImport' | 'Account' | 'ActorFollow'>
81 95
82// ############################################################################ 96// ############################################################################
@@ -85,7 +99,7 @@ export type UserNotificationModelForApi =
85 MUserNotification & 99 MUserNotification &
86 Use<'Video', UserNotificationIncludes.VideoIncludeChannel> & 100 Use<'Video', UserNotificationIncludes.VideoIncludeChannel> &
87 Use<'Comment', UserNotificationIncludes.VideoCommentInclude> & 101 Use<'Comment', UserNotificationIncludes.VideoCommentInclude> &
88 Use<'VideoAbuse', UserNotificationIncludes.VideoAbuseInclude> & 102 Use<'Abuse', UserNotificationIncludes.AbuseInclude> &
89 Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> & 103 Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> &
90 Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> & 104 Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> &
91 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & 105 Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index bd69c8a4b..25db23898 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -2,7 +2,6 @@ export * from './schedule-video-update'
2export * from './tag' 2export * from './tag'
3export * from './thumbnail' 3export * from './thumbnail'
4export * from './video' 4export * from './video'
5export * from './video-abuse'
6export * from './video-blacklist' 5export * from './video-blacklist'
7export * from './video-caption' 6export * from './video-caption'
8export * from './video-change-ownership' 7export * from './video-change-ownership'
diff --git a/server/types/models/video/video-abuse.ts b/server/types/models/video/video-abuse.ts
deleted file mode 100644
index 279a87cf3..000000000
--- a/server/types/models/video/video-abuse.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { VideoAbuseModel } from '../../../models/video/video-abuse'
2import { PickWith } from '@shared/core-utils'
3import { MVideoAccountLightBlacklistAllFiles, MVideo } from './video'
4import { MAccountDefault, MAccountFormattable } from '../account'
5
6type Use<K extends keyof VideoAbuseModel, M> = PickWith<VideoAbuseModel, K, M>
7
8// ############################################################################
9
10export type MVideoAbuse = Omit<VideoAbuseModel, 'Account' | 'Video' | 'toActivityPubObject'>
11
12// ############################################################################
13
14export type MVideoAbuseId = Pick<VideoAbuseModel, 'id'>
15
16export type MVideoAbuseVideo =
17 MVideoAbuse &
18 Pick<VideoAbuseModel, 'toActivityPubObject'> &
19 Use<'Video', MVideo>
20
21export type MVideoAbuseAccountVideo =
22 MVideoAbuse &
23 Pick<VideoAbuseModel, 'toActivityPubObject'> &
24 Use<'Video', MVideoAccountLightBlacklistAllFiles> &
25 Use<'Account', MAccountDefault>
26
27// ############################################################################
28
29// Format for API or AP object
30
31export type MVideoAbuseFormattable =
32 MVideoAbuse &
33 Use<'Account', MAccountFormattable> &
34 Use<'Video', Pick<MVideoAccountLightBlacklistAllFiles,
35 'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>>
diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts
index cac801e55..7595e6d86 100644
--- a/server/typings/express/index.d.ts
+++ b/server/typings/express/index.d.ts
@@ -1,5 +1,6 @@
1import { RegisterServerAuthExternalOptions } from '@server/types' 1import { RegisterServerAuthExternalOptions } from '@server/types'
2import { 2import {
3 MAbuse,
3 MAccountBlocklist, 4 MAccountBlocklist,
4 MActorUrl, 5 MActorUrl,
5 MStreamingPlaylist, 6 MStreamingPlaylist,
@@ -26,7 +27,6 @@ import {
26 MComment, 27 MComment,
27 MCommentOwnerVideoReply, 28 MCommentOwnerVideoReply,
28 MUserDefault, 29 MUserDefault,
29 MVideoAbuse,
30 MVideoBlacklist, 30 MVideoBlacklist,
31 MVideoCaptionVideo, 31 MVideoCaptionVideo,
32 MVideoFullLight, 32 MVideoFullLight,
@@ -77,7 +77,7 @@ declare module 'express' {
77 77
78 videoCaption?: MVideoCaptionVideo 78 videoCaption?: MVideoCaptionVideo
79 79
80 videoAbuse?: MVideoAbuse 80 abuse?: MAbuse
81 81
82 videoStreamingPlaylist?: MStreamingPlaylist 82 videoStreamingPlaylist?: MStreamingPlaylist
83 83
diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts
index 2ac0c6338..af4d23856 100644
--- a/shared/extra-utils/index.ts
+++ b/shared/extra-utils/index.ts
@@ -17,6 +17,7 @@ export * from './videos/services'
17export * from './videos/video-playlists' 17export * from './videos/video-playlists'
18export * from './users/users' 18export * from './users/users'
19export * from './users/accounts' 19export * from './users/accounts'
20export * from './moderation/abuses'
20export * from './videos/video-abuses' 21export * from './videos/video-abuses'
21export * from './videos/video-blacklist' 22export * from './videos/video-blacklist'
22export * from './videos/video-captions' 23export * from './videos/video-captions'
diff --git a/shared/extra-utils/moderation/abuses.ts b/shared/extra-utils/moderation/abuses.ts
new file mode 100644
index 000000000..62af9556e
--- /dev/null
+++ b/shared/extra-utils/moderation/abuses.ts
@@ -0,0 +1,156 @@
1
2import { AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
3import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
4
5function reportAbuse (options: {
6 url: string
7 token: string
8
9 reason: string
10
11 accountId?: number
12 videoId?: number
13 commentId?: number
14
15 predefinedReasons?: AbusePredefinedReasonsString[]
16
17 startAt?: number
18 endAt?: number
19
20 statusCodeExpected?: number
21}) {
22 const path = '/api/v1/abuses'
23
24 const video = options.videoId ? {
25 id: options.videoId,
26 startAt: options.startAt,
27 endAt: options.endAt
28 } : undefined
29
30 const comment = options.commentId ? {
31 id: options.commentId
32 } : undefined
33
34 const account = options.accountId ? {
35 id: options.accountId
36 } : undefined
37
38 const body = {
39 account,
40 video,
41 comment,
42
43 reason: options.reason,
44 predefinedReasons: options.predefinedReasons
45 }
46
47 return makePostBodyRequest({
48 url: options.url,
49 path,
50 token: options.token,
51
52 fields: body,
53 statusCodeExpected: options.statusCodeExpected || 200
54 })
55}
56
57function getAbusesList (options: {
58 url: string
59 token: string
60
61 start?: number
62 count?: number
63 sort?: string
64
65 id?: number
66 predefinedReason?: AbusePredefinedReasonsString
67 search?: string
68 filter?: AbuseFilter
69 state?: AbuseState
70 videoIs?: AbuseVideoIs
71 searchReporter?: string
72 searchReportee?: string
73 searchVideo?: string
74 searchVideoChannel?: string
75}) {
76 const {
77 url,
78 token,
79 start,
80 count,
81 sort,
82 id,
83 predefinedReason,
84 search,
85 filter,
86 state,
87 videoIs,
88 searchReporter,
89 searchReportee,
90 searchVideo,
91 searchVideoChannel
92 } = options
93 const path = '/api/v1/abuses'
94
95 const query = {
96 id,
97 predefinedReason,
98 search,
99 state,
100 filter,
101 videoIs,
102 start,
103 count,
104 sort: sort || 'createdAt',
105 searchReporter,
106 searchReportee,
107 searchVideo,
108 searchVideoChannel
109 }
110
111 return makeGetRequest({
112 url,
113 path,
114 token,
115 query,
116 statusCodeExpected: 200
117 })
118}
119
120function updateAbuse (
121 url: string,
122 token: string,
123 abuseId: number,
124 body: AbuseUpdate,
125 statusCodeExpected = 204
126) {
127 const path = '/api/v1/abuses/' + abuseId
128
129 return makePutBodyRequest({
130 url,
131 token,
132 path,
133 fields: body,
134 statusCodeExpected
135 })
136}
137
138function deleteAbuse (url: string, token: string, abuseId: number, statusCodeExpected = 204) {
139 const path = '/api/v1/abuses/' + abuseId
140
141 return makeDeleteRequest({
142 url,
143 token,
144 path,
145 statusCodeExpected
146 })
147}
148
149// ---------------------------------------------------------------------------
150
151export {
152 reportAbuse,
153 getAbusesList,
154 updateAbuse,
155 deleteAbuse
156}
diff --git a/shared/extra-utils/server/servers.ts b/shared/extra-utils/server/servers.ts
index 0f883d839..994aac628 100644
--- a/shared/extra-utils/server/servers.ts
+++ b/shared/extra-utils/server/servers.ts
@@ -37,8 +37,8 @@ interface ServerInfo {
37 video?: { 37 video?: {
38 id: number 38 id: number
39 uuid: string 39 uuid: string
40 name: string 40 name?: string
41 account: { 41 account?: {
42 name: string 42 name: string
43 } 43 }
44 } 44 }
diff --git a/shared/extra-utils/users/user-notifications.ts b/shared/extra-utils/users/user-notifications.ts
index a17a39de9..2061e3353 100644
--- a/shared/extra-utils/users/user-notifications.ts
+++ b/shared/extra-utils/users/user-notifications.ts
@@ -139,13 +139,17 @@ async function checkNotification (
139} 139}
140 140
141function checkVideo (video: any, videoName?: string, videoUUID?: string) { 141function checkVideo (video: any, videoName?: string, videoUUID?: string) {
142 expect(video.name).to.be.a('string') 142 if (videoName) {
143 expect(video.name).to.not.be.empty 143 expect(video.name).to.be.a('string')
144 if (videoName) expect(video.name).to.equal(videoName) 144 expect(video.name).to.not.be.empty
145 expect(video.name).to.equal(videoName)
146 }
145 147
146 expect(video.uuid).to.be.a('string') 148 if (videoUUID) {
147 expect(video.uuid).to.not.be.empty 149 expect(video.uuid).to.be.a('string')
148 if (videoUUID) expect(video.uuid).to.equal(videoUUID) 150 expect(video.uuid).to.not.be.empty
151 expect(video.uuid).to.equal(videoUUID)
152 }
149 153
150 expect(video.id).to.be.a('number') 154 expect(video.id).to.be.a('number')
151} 155}
@@ -436,18 +440,43 @@ async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string,
436} 440}
437 441
438async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) { 442async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
439 const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS 443 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
444
445 function notificationChecker (notification: UserNotification, type: CheckerType) {
446 if (type === 'presence') {
447 expect(notification).to.not.be.undefined
448 expect(notification.type).to.equal(notificationType)
449
450 expect(notification.abuse.id).to.be.a('number')
451 checkVideo(notification.abuse.video, videoName, videoUUID)
452 } else {
453 expect(notification).to.satisfy((n: UserNotification) => {
454 return n === undefined || n.abuse === undefined || n.abuse.video.uuid !== videoUUID
455 })
456 }
457 }
458
459 function emailNotificationFinder (email: object) {
460 const text = email['text']
461 return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
462 }
463
464 await checkNotification(base, notificationChecker, emailNotificationFinder, type)
465}
466
467async function checkNewCommentAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
468 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
440 469
441 function notificationChecker (notification: UserNotification, type: CheckerType) { 470 function notificationChecker (notification: UserNotification, type: CheckerType) {
442 if (type === 'presence') { 471 if (type === 'presence') {
443 expect(notification).to.not.be.undefined 472 expect(notification).to.not.be.undefined
444 expect(notification.type).to.equal(notificationType) 473 expect(notification.type).to.equal(notificationType)
445 474
446 expect(notification.videoAbuse.id).to.be.a('number') 475 expect(notification.abuse.id).to.be.a('number')
447 checkVideo(notification.videoAbuse.video, videoName, videoUUID) 476 checkVideo(notification.abuse.comment.video, videoName, videoUUID)
448 } else { 477 } else {
449 expect(notification).to.satisfy((n: UserNotification) => { 478 expect(notification).to.satisfy((n: UserNotification) => {
450 return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID 479 return n === undefined || n.abuse === undefined || n.abuse.comment.video.uuid !== videoUUID
451 }) 480 })
452 } 481 }
453 } 482 }
@@ -460,6 +489,31 @@ async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUU
460 await checkNotification(base, notificationChecker, emailNotificationFinder, type) 489 await checkNotification(base, notificationChecker, emailNotificationFinder, type)
461} 490}
462 491
492async function checkNewAccountAbuseForModerators (base: CheckerBaseParams, displayName: string, type: CheckerType) {
493 const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
494
495 function notificationChecker (notification: UserNotification, type: CheckerType) {
496 if (type === 'presence') {
497 expect(notification).to.not.be.undefined
498 expect(notification.type).to.equal(notificationType)
499
500 expect(notification.abuse.id).to.be.a('number')
501 expect(notification.abuse.account.displayName).to.equal(displayName)
502 } else {
503 expect(notification).to.satisfy((n: UserNotification) => {
504 return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName
505 })
506 }
507 }
508
509 function emailNotificationFinder (email: object) {
510 const text = email['text']
511 return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1
512 }
513
514 await checkNotification(base, notificationChecker, emailNotificationFinder, type)
515}
516
463async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) { 517async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
464 const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS 518 const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS
465 519
@@ -516,7 +570,7 @@ function getAllNotificationsSettings () {
516 return { 570 return {
517 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 571 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
518 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 572 newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
519 videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 573 abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
520 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 574 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
521 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 575 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
522 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, 576 myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
@@ -541,6 +595,9 @@ async function prepareNotificationsTest (serversCount = 3) {
541 smtp: { 595 smtp: {
542 hostname: 'localhost', 596 hostname: 'localhost',
543 port 597 port
598 },
599 signup: {
600 limit: 20
544 } 601 }
545 } 602 }
546 const servers = await flushAndRunMultipleServers(serversCount, overrideConfig) 603 const servers = await flushAndRunMultipleServers(serversCount, overrideConfig)
@@ -623,5 +680,7 @@ export {
623 markAsReadNotifications, 680 markAsReadNotifications,
624 getLastNotification, 681 getLastNotification,
625 checkNewInstanceFollower, 682 checkNewInstanceFollower,
626 prepareNotificationsTest 683 prepareNotificationsTest,
684 checkNewCommentAbuseForModerators,
685 checkNewAccountAbuseForModerators
627} 686}
diff --git a/shared/extra-utils/videos/video-abuses.ts b/shared/extra-utils/videos/video-abuses.ts
index ff006672a..8827b8196 100644
--- a/shared/extra-utils/videos/video-abuses.ts
+++ b/shared/extra-utils/videos/video-abuses.ts
@@ -1,15 +1,15 @@
1import * as request from 'supertest' 1import * as request from 'supertest'
2import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' 2import { AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
3import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' 3import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
4import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models' 4
5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' 5// FIXME: deprecated in 2.3. Remove this file
6 6
7function reportVideoAbuse ( 7function reportVideoAbuse (
8 url: string, 8 url: string,
9 token: string, 9 token: string,
10 videoId: number | string, 10 videoId: number | string,
11 reason: string, 11 reason: string,
12 predefinedReasons?: VideoAbusePredefinedReasonsString[], 12 predefinedReasons?: AbusePredefinedReasonsString[],
13 startAt?: number, 13 startAt?: number,
14 endAt?: number, 14 endAt?: number,
15 specialStatus = 200 15 specialStatus = 200
@@ -28,10 +28,10 @@ function getVideoAbusesList (options: {
28 url: string 28 url: string
29 token: string 29 token: string
30 id?: number 30 id?: number
31 predefinedReason?: VideoAbusePredefinedReasonsString 31 predefinedReason?: AbusePredefinedReasonsString
32 search?: string 32 search?: string
33 state?: VideoAbuseState 33 state?: AbuseState
34 videoIs?: VideoAbuseVideoIs 34 videoIs?: AbuseVideoIs
35 searchReporter?: string 35 searchReporter?: string
36 searchReportee?: string 36 searchReportee?: string
37 searchVideo?: string 37 searchVideo?: string
@@ -79,7 +79,7 @@ function updateVideoAbuse (
79 token: string, 79 token: string,
80 videoId: string | number, 80 videoId: string | number,
81 videoAbuseId: number, 81 videoAbuseId: number,
82 body: VideoAbuseUpdate, 82 body: AbuseUpdate,
83 statusCodeExpected = 204 83 statusCodeExpected = 204
84) { 84) {
85 const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId 85 const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts
index 31b9e4673..5b4ce214a 100644
--- a/shared/models/activitypub/activity.ts
+++ b/shared/models/activitypub/activity.ts
@@ -1,12 +1,12 @@
1import { ActivityPubActor } from './activitypub-actor' 1import { ActivityPubActor } from './activitypub-actor'
2import { ActivityPubSignature } from './activitypub-signature' 2import { ActivityPubSignature } from './activitypub-signature'
3import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects' 3import { ActivityFlagReasonObject, CacheFileObject, VideoTorrentObject } from './objects'
4import { AbuseObject } from './objects/abuse-object'
4import { DislikeObject } from './objects/dislike-object' 5import { DislikeObject } from './objects/dislike-object'
5import { VideoAbuseObject } from './objects/video-abuse-object'
6import { VideoCommentObject } from './objects/video-comment-object'
7import { ViewObject } from './objects/view-object'
8import { APObject } from './objects/object.model' 6import { APObject } from './objects/object.model'
9import { PlaylistObject } from './objects/playlist-object' 7import { PlaylistObject } from './objects/playlist-object'
8import { VideoCommentObject } from './objects/video-comment-object'
9import { ViewObject } from './objects/view-object'
10 10
11export type Activity = 11export type Activity =
12 ActivityCreate | 12 ActivityCreate |
@@ -53,7 +53,7 @@ export interface BaseActivity {
53 53
54export interface ActivityCreate extends BaseActivity { 54export interface ActivityCreate extends BaseActivity {
55 type: 'Create' 55 type: 'Create'
56 object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject 56 object: VideoTorrentObject | AbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
57} 57}
58 58
59export interface ActivityUpdate extends BaseActivity { 59export interface ActivityUpdate extends BaseActivity {
diff --git a/shared/models/activitypub/objects/video-abuse-object.ts b/shared/models/activitypub/objects/abuse-object.ts
index 73add8ef4..ad45cc064 100644
--- a/shared/models/activitypub/objects/video-abuse-object.ts
+++ b/shared/models/activitypub/objects/abuse-object.ts
@@ -1,10 +1,12 @@
1import { ActivityFlagReasonObject } from './common-objects' 1import { ActivityFlagReasonObject } from './common-objects'
2 2
3export interface VideoAbuseObject { 3export interface AbuseObject {
4 type: 'Flag' 4 type: 'Flag'
5 content: string 5 content: string
6 object: string | string[] 6 object: string | string[]
7
7 tag?: ActivityFlagReasonObject[] 8 tag?: ActivityFlagReasonObject[]
9
8 startAt?: number 10 startAt?: number
9 endAt?: number 11 endAt?: number
10} 12}
diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts
index 096d422ea..711ce45f4 100644
--- a/shared/models/activitypub/objects/common-objects.ts
+++ b/shared/models/activitypub/objects/common-objects.ts
@@ -1,4 +1,4 @@
1import { VideoAbusePredefinedReasonsString } from '@shared/models/videos' 1import { AbusePredefinedReasonsString } from '@shared/models'
2 2
3export interface ActivityIdentifierObject { 3export interface ActivityIdentifierObject {
4 identifier: string 4 identifier: string
@@ -85,7 +85,7 @@ export interface ActivityMentionObject {
85 85
86export interface ActivityFlagReasonObject { 86export interface ActivityFlagReasonObject {
87 type: 'Hashtag' 87 type: 'Hashtag'
88 name: VideoAbusePredefinedReasonsString 88 name: AbusePredefinedReasonsString
89} 89}
90 90
91export type ActivityTagObject = 91export type ActivityTagObject =
diff --git a/shared/models/activitypub/objects/index.ts b/shared/models/activitypub/objects/index.ts
index fba61e12f..a6a20e87a 100644
--- a/shared/models/activitypub/objects/index.ts
+++ b/shared/models/activitypub/objects/index.ts
@@ -1,6 +1,6 @@
1export * from './abuse-object'
1export * from './cache-file-object' 2export * from './cache-file-object'
2export * from './common-objects' 3export * from './common-objects'
3export * from './video-abuse-object' 4export * from './dislike-object'
4export * from './video-torrent-object' 5export * from './video-torrent-object'
5export * from './view-object' 6export * from './view-object'
6export * from './dislike-object'
diff --git a/shared/models/index.ts b/shared/models/index.ts
index 3d4bdedde..a68f57148 100644
--- a/shared/models/index.ts
+++ b/shared/models/index.ts
@@ -1,7 +1,7 @@
1export * from './activitypub' 1export * from './activitypub'
2export * from './actors' 2export * from './actors'
3export * from './avatars' 3export * from './avatars'
4export * from './blocklist' 4export * from './moderation'
5export * from './bulk' 5export * from './bulk'
6export * from './redundancy' 6export * from './redundancy'
7export * from './users' 7export * from './users'
@@ -14,4 +14,3 @@ export * from './search'
14export * from './server' 14export * from './server'
15export * from './oauth-client-local.model' 15export * from './oauth-client-local.model'
16export * from './result-list.model' 16export * from './result-list.model'
17export * from './server/server-config.model'
diff --git a/shared/models/moderation/abuse/abuse-create.model.ts b/shared/models/moderation/abuse/abuse-create.model.ts
new file mode 100644
index 000000000..b0358dbb9
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse-create.model.ts
@@ -0,0 +1,29 @@
1import { AbusePredefinedReasonsString } from './abuse-reason.model'
2
3export interface AbuseCreate {
4 reason: string
5
6 predefinedReasons?: AbusePredefinedReasonsString[]
7
8 account?: {
9 id: number
10 }
11
12 video?: {
13 id: number
14 startAt?: number
15 endAt?: number
16 }
17
18 comment?: {
19 id: number
20 }
21}
22
23// FIXME: deprecated in 2.3. Remove it
24export interface VideoAbuseCreate {
25 reason: string
26 predefinedReasons?: AbusePredefinedReasonsString[]
27 startAt?: number
28 endAt?: number
29}
diff --git a/shared/models/moderation/abuse/abuse-filter.type.ts b/shared/models/moderation/abuse/abuse-filter.type.ts
new file mode 100644
index 000000000..7dafc6d77
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse-filter.type.ts
@@ -0,0 +1 @@
export type AbuseFilter = 'video' | 'comment' | 'account'
diff --git a/shared/models/moderation/abuse/abuse-reason.model.ts b/shared/models/moderation/abuse/abuse-reason.model.ts
new file mode 100644
index 000000000..36875969d
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse-reason.model.ts
@@ -0,0 +1,33 @@
1export enum AbusePredefinedReasons {
2 VIOLENT_OR_REPULSIVE = 1,
3 HATEFUL_OR_ABUSIVE,
4 SPAM_OR_MISLEADING,
5 PRIVACY,
6 RIGHTS,
7 SERVER_RULES,
8 THUMBNAILS,
9 CAPTIONS
10}
11
12export type AbusePredefinedReasonsString =
13 'violentOrRepulsive' |
14 'hatefulOrAbusive' |
15 'spamOrMisleading' |
16 'privacy' |
17 'rights' |
18 'serverRules' |
19 'thumbnails' |
20 'captions'
21
22export const abusePredefinedReasonsMap: {
23 [key in AbusePredefinedReasonsString]: AbusePredefinedReasons
24} = {
25 violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
26 hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
27 spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING,
28 privacy: AbusePredefinedReasons.PRIVACY,
29 rights: AbusePredefinedReasons.RIGHTS,
30 serverRules: AbusePredefinedReasons.SERVER_RULES,
31 thumbnails: AbusePredefinedReasons.THUMBNAILS,
32 captions: AbusePredefinedReasons.CAPTIONS
33}
diff --git a/shared/models/videos/abuse/video-abuse-state.model.ts b/shared/models/moderation/abuse/abuse-state.model.ts
index 529f034bd..b00cccad8 100644
--- a/shared/models/videos/abuse/video-abuse-state.model.ts
+++ b/shared/models/moderation/abuse/abuse-state.model.ts
@@ -1,4 +1,4 @@
1export enum VideoAbuseState { 1export enum AbuseState {
2 PENDING = 1, 2 PENDING = 1,
3 REJECTED = 2, 3 REJECTED = 2,
4 ACCEPTED = 3 4 ACCEPTED = 3
diff --git a/shared/models/moderation/abuse/abuse-update.model.ts b/shared/models/moderation/abuse/abuse-update.model.ts
new file mode 100644
index 000000000..4360fe7ac
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse-update.model.ts
@@ -0,0 +1,7 @@
1import { AbuseState } from './abuse-state.model'
2
3export interface AbuseUpdate {
4 moderationComment?: string
5
6 state?: AbuseState
7}
diff --git a/shared/models/moderation/abuse/abuse-video-is.type.ts b/shared/models/moderation/abuse/abuse-video-is.type.ts
new file mode 100644
index 000000000..74937f3b9
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse-video-is.type.ts
@@ -0,0 +1 @@
export type AbuseVideoIs = 'deleted' | 'blacklisted'
diff --git a/shared/models/moderation/abuse/abuse.model.ts b/shared/models/moderation/abuse/abuse.model.ts
new file mode 100644
index 000000000..0a0c6bd35
--- /dev/null
+++ b/shared/models/moderation/abuse/abuse.model.ts
@@ -0,0 +1,73 @@
1import { Account } from '../../actors/account.model'
2import { AbuseState } from './abuse-state.model'
3import { AbusePredefinedReasonsString } from './abuse-reason.model'
4import { VideoConstant } from '../../videos/video-constant.model'
5import { VideoChannel } from '../../videos/channel/video-channel.model'
6
7export interface VideoAbuse {
8 id: number
9 name: string
10 uuid: string
11 nsfw: boolean
12
13 deleted: boolean
14 blacklisted: boolean
15
16 startAt: number | null
17 endAt: number | null
18
19 thumbnailPath?: string
20 channel?: VideoChannel
21
22 countReports: number
23 nthReport: number
24}
25
26export interface VideoCommentAbuse {
27 id: number
28 threadId: number
29
30 video: {
31 id: number
32 name: string
33 uuid: string
34 }
35
36 text: string
37
38 deleted: boolean
39}
40
41export interface Abuse {
42 id: number
43
44 reason: string
45 predefinedReasons?: AbusePredefinedReasonsString[]
46
47 reporterAccount: Account
48 flaggedAccount: Account
49
50 state: VideoConstant<AbuseState>
51 moderationComment?: string
52
53 video?: VideoAbuse
54 comment?: VideoCommentAbuse
55
56 createdAt: Date
57 updatedAt: Date
58
59 countReportsForReporter?: number
60 countReportsForReportee?: number
61
62 // FIXME: deprecated in 2.3, remove the following properties
63
64 // @deprecated
65 startAt?: null
66 // @deprecated
67 endAt?: null
68
69 // @deprecated
70 count?: number
71 // @deprecated
72 nth?: number
73}
diff --git a/shared/models/moderation/abuse/index.ts b/shared/models/moderation/abuse/index.ts
new file mode 100644
index 000000000..55046426a
--- /dev/null
+++ b/shared/models/moderation/abuse/index.ts
@@ -0,0 +1,7 @@
1export * from './abuse-create.model'
2export * from './abuse-filter.type'
3export * from './abuse-reason.model'
4export * from './abuse-state.model'
5export * from './abuse-update.model'
6export * from './abuse-video-is.type'
7export * from './abuse.model'
diff --git a/shared/models/blocklist/account-block.model.ts b/shared/models/moderation/account-block.model.ts
index a942ed614..a942ed614 100644
--- a/shared/models/blocklist/account-block.model.ts
+++ b/shared/models/moderation/account-block.model.ts
diff --git a/shared/models/blocklist/index.ts b/shared/models/moderation/index.ts
index fc7873270..8b6042e97 100644
--- a/shared/models/blocklist/index.ts
+++ b/shared/models/moderation/index.ts
@@ -1,2 +1,3 @@
1export * from './abuse'
1export * from './account-block.model' 2export * from './account-block.model'
2export * from './server-block.model' 3export * from './server-block.model'
diff --git a/shared/models/blocklist/server-block.model.ts b/shared/models/moderation/server-block.model.ts
index a8b8af0b7..a8b8af0b7 100644
--- a/shared/models/blocklist/server-block.model.ts
+++ b/shared/models/moderation/server-block.model.ts
diff --git a/shared/models/users/user-notification-setting.model.ts b/shared/models/users/user-notification-setting.model.ts
index 451f40d58..4e2230a76 100644
--- a/shared/models/users/user-notification-setting.model.ts
+++ b/shared/models/users/user-notification-setting.model.ts
@@ -7,7 +7,7 @@ export enum UserNotificationSettingValue {
7export interface UserNotificationSetting { 7export interface UserNotificationSetting {
8 newVideoFromSubscription: UserNotificationSettingValue 8 newVideoFromSubscription: UserNotificationSettingValue
9 newCommentOnMyVideo: UserNotificationSettingValue 9 newCommentOnMyVideo: UserNotificationSettingValue
10 videoAbuseAsModerator: UserNotificationSettingValue 10 abuseAsModerator: UserNotificationSettingValue
11 videoAutoBlacklistAsModerator: UserNotificationSettingValue 11 videoAutoBlacklistAsModerator: UserNotificationSettingValue
12 blacklistOnMyVideo: UserNotificationSettingValue 12 blacklistOnMyVideo: UserNotificationSettingValue
13 myVideoPublished: UserNotificationSettingValue 13 myVideoPublished: UserNotificationSettingValue
diff --git a/shared/models/users/user-notification.model.ts b/shared/models/users/user-notification.model.ts
index e9be1ca7f..5f7c33976 100644
--- a/shared/models/users/user-notification.model.ts
+++ b/shared/models/users/user-notification.model.ts
@@ -3,7 +3,7 @@ import { FollowState } from '../actors'
3export enum UserNotificationType { 3export enum UserNotificationType {
4 NEW_VIDEO_FROM_SUBSCRIPTION = 1, 4 NEW_VIDEO_FROM_SUBSCRIPTION = 1,
5 NEW_COMMENT_ON_MY_VIDEO = 2, 5 NEW_COMMENT_ON_MY_VIDEO = 2,
6 NEW_VIDEO_ABUSE_FOR_MODERATORS = 3, 6 NEW_ABUSE_FOR_MODERATORS = 3,
7 7
8 BLACKLIST_ON_MY_VIDEO = 4, 8 BLACKLIST_ON_MY_VIDEO = 4,
9 UNBLACKLIST_ON_MY_VIDEO = 5, 9 UNBLACKLIST_ON_MY_VIDEO = 5,
@@ -64,9 +64,22 @@ export interface UserNotification {
64 video: VideoInfo 64 video: VideoInfo
65 } 65 }
66 66
67 videoAbuse?: { 67 abuse?: {
68 id: number 68 id: number
69 video: VideoInfo 69
70 video?: VideoInfo
71
72 comment?: {
73 threadId: number
74
75 video: {
76 id: number
77 uuid: string
78 name: string
79 }
80 }
81
82 account?: ActorInfo
70 } 83 }
71 84
72 videoBlacklist?: { 85 videoBlacklist?: {
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts
index 2f88a65de..4a7ae4373 100644
--- a/shared/models/users/user-right.enum.ts
+++ b/shared/models/users/user-right.enum.ts
@@ -11,7 +11,7 @@ export enum UserRight {
11 11
12 MANAGE_SERVER_REDUNDANCY, 12 MANAGE_SERVER_REDUNDANCY,
13 13
14 MANAGE_VIDEO_ABUSES, 14 MANAGE_ABUSES,
15 15
16 MANAGE_JOBS, 16 MANAGE_JOBS,
17 17
diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts
index 2b08b5850..772988c0c 100644
--- a/shared/models/users/user-role.ts
+++ b/shared/models/users/user-role.ts
@@ -20,7 +20,7 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = {
20 20
21 [UserRole.MODERATOR]: [ 21 [UserRole.MODERATOR]: [
22 UserRight.MANAGE_VIDEO_BLACKLIST, 22 UserRight.MANAGE_VIDEO_BLACKLIST,
23 UserRight.MANAGE_VIDEO_ABUSES, 23 UserRight.MANAGE_ABUSES,
24 UserRight.REMOVE_ANY_VIDEO, 24 UserRight.REMOVE_ANY_VIDEO,
25 UserRight.REMOVE_ANY_VIDEO_CHANNEL, 25 UserRight.REMOVE_ANY_VIDEO_CHANNEL,
26 UserRight.REMOVE_ANY_VIDEO_PLAYLIST, 26 UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 6c959ceea..859736b2f 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -31,10 +31,13 @@ export interface User {
31 videoQuotaDaily: number 31 videoQuotaDaily: number
32 videoQuotaUsed?: number 32 videoQuotaUsed?: number
33 videoQuotaUsedDaily?: number 33 videoQuotaUsedDaily?: number
34
34 videosCount?: number 35 videosCount?: number
35 videoAbusesCount?: number 36
36 videoAbusesAcceptedCount?: number 37 abusesCount?: number
37 videoAbusesCreatedCount?: number 38 abusesAcceptedCount?: number
39 abusesCreatedCount?: number
40
38 videoCommentsCount? : number 41 videoCommentsCount? : number
39 42
40 theme: string 43 theme: string
diff --git a/shared/models/videos/abuse/index.ts b/shared/models/videos/abuse/index.ts
deleted file mode 100644
index f70bc736f..000000000
--- a/shared/models/videos/abuse/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export * from './video-abuse-create.model'
2export * from './video-abuse-reason.model'
3export * from './video-abuse-state.model'
4export * from './video-abuse-update.model'
5export * from './video-abuse-video-is.type'
6export * from './video-abuse.model'
diff --git a/shared/models/videos/abuse/video-abuse-create.model.ts b/shared/models/videos/abuse/video-abuse-create.model.ts
deleted file mode 100644
index c93cb8b2c..000000000
--- a/shared/models/videos/abuse/video-abuse-create.model.ts
+++ /dev/null
@@ -1,8 +0,0 @@
1import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
2
3export interface VideoAbuseCreate {
4 reason: string
5 predefinedReasons?: VideoAbusePredefinedReasonsString[]
6 startAt?: number
7 endAt?: number
8}
diff --git a/shared/models/videos/abuse/video-abuse-reason.model.ts b/shared/models/videos/abuse/video-abuse-reason.model.ts
deleted file mode 100644
index 9064f0c1a..000000000
--- a/shared/models/videos/abuse/video-abuse-reason.model.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1export enum VideoAbusePredefinedReasons {
2 VIOLENT_OR_REPULSIVE = 1,
3 HATEFUL_OR_ABUSIVE,
4 SPAM_OR_MISLEADING,
5 PRIVACY,
6 RIGHTS,
7 SERVER_RULES,
8 THUMBNAILS,
9 CAPTIONS
10}
11
12export type VideoAbusePredefinedReasonsString =
13 'violentOrRepulsive' |
14 'hatefulOrAbusive' |
15 'spamOrMisleading' |
16 'privacy' |
17 'rights' |
18 'serverRules' |
19 'thumbnails' |
20 'captions'
21
22export const videoAbusePredefinedReasonsMap: {
23 [key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons
24} = {
25 violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
26 hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
27 spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING,
28 privacy: VideoAbusePredefinedReasons.PRIVACY,
29 rights: VideoAbusePredefinedReasons.RIGHTS,
30 serverRules: VideoAbusePredefinedReasons.SERVER_RULES,
31 thumbnails: VideoAbusePredefinedReasons.THUMBNAILS,
32 captions: VideoAbusePredefinedReasons.CAPTIONS
33}
diff --git a/shared/models/videos/abuse/video-abuse-update.model.ts b/shared/models/videos/abuse/video-abuse-update.model.ts
deleted file mode 100644
index 9b32aae48..000000000
--- a/shared/models/videos/abuse/video-abuse-update.model.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1import { VideoAbuseState } from './video-abuse-state.model'
2
3export interface VideoAbuseUpdate {
4 moderationComment?: string
5 state?: VideoAbuseState
6}
diff --git a/shared/models/videos/abuse/video-abuse-video-is.type.ts b/shared/models/videos/abuse/video-abuse-video-is.type.ts
deleted file mode 100644
index e86018993..000000000
--- a/shared/models/videos/abuse/video-abuse-video-is.type.ts
+++ /dev/null
@@ -1 +0,0 @@
1export type VideoAbuseVideoIs = 'deleted' | 'blacklisted'
diff --git a/shared/models/videos/abuse/video-abuse.model.ts b/shared/models/videos/abuse/video-abuse.model.ts
deleted file mode 100644
index 38605dcac..000000000
--- a/shared/models/videos/abuse/video-abuse.model.ts
+++ /dev/null
@@ -1,38 +0,0 @@
1import { Account } from '../../actors/index'
2import { VideoConstant } from '../video-constant.model'
3import { VideoAbuseState } from './video-abuse-state.model'
4import { VideoChannel } from '../channel/video-channel.model'
5import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
6
7export interface VideoAbuse {
8 id: number
9 reason: string
10 predefinedReasons?: VideoAbusePredefinedReasonsString[]
11 reporterAccount: Account
12
13 state: VideoConstant<VideoAbuseState>
14 moderationComment?: string
15
16 video: {
17 id: number
18 name: string
19 uuid: string
20 nsfw: boolean
21 deleted: boolean
22 blacklisted: boolean
23 thumbnailPath?: string
24 channel?: VideoChannel
25 }
26
27 createdAt: Date
28 updatedAt: Date
29
30 startAt: number
31 endAt: number
32
33 count?: number
34 nth?: number
35
36 countReportsForReporter?: number
37 countReportsForReportee?: number
38}
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index e1d96b40a..20b9638ab 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -1,4 +1,3 @@
1export * from './abuse'
2export * from './blacklist' 1export * from './blacklist'
3export * from './caption' 2export * from './caption'
4export * from './channel' 3export * from './channel'
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 79f75063f..a0d086324 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -106,9 +106,9 @@ tags:
106 Managing plugins installed from a local path or from NPM, or search for new ones. 106 Managing plugins installed from a local path or from NPM, or search for new ones.
107 externalDocs: 107 externalDocs:
108 url: https://docs.joinpeertube.org/#/api-plugins 108 url: https://docs.joinpeertube.org/#/api-plugins
109 - name: Video Abuses 109 - name: Abuses
110 description: | 110 description: |
111 Video abuses deal with reports of local or remote videos alike. 111 Abuses deal with reports of local or remote videos/comments/accounts alike.
112 - name: Video 112 - name: Video
113 description: | 113 description: |
114 Operations dealing with listing, uploading, fetching or modifying videos. 114 Operations dealing with listing, uploading, fetching or modifying videos.
@@ -166,7 +166,7 @@ x-tagGroups:
166 - Search 166 - Search
167 - name: Moderation 167 - name: Moderation
168 tags: 168 tags:
169 - Video Abuses 169 - Abuses
170 - Video Blocks 170 - Video Blocks
171 - Account Blocks 171 - Account Blocks
172 - Server Blocks 172 - Server Blocks
@@ -893,7 +893,7 @@ paths:
893 $ref: '#/components/schemas/NotificationSettingValue' 893 $ref: '#/components/schemas/NotificationSettingValue'
894 newCommentOnMyVideo: 894 newCommentOnMyVideo:
895 $ref: '#/components/schemas/NotificationSettingValue' 895 $ref: '#/components/schemas/NotificationSettingValue'
896 videoAbuseAsModerator: 896 abuseAsModerator:
897 $ref: '#/components/schemas/NotificationSettingValue' 897 $ref: '#/components/schemas/NotificationSettingValue'
898 videoAutoBlacklistAsModerator: 898 videoAutoBlacklistAsModerator:
899 $ref: '#/components/schemas/NotificationSettingValue' 899 $ref: '#/components/schemas/NotificationSettingValue'
@@ -1471,16 +1471,15 @@ paths:
1471 description: HTTP or Torrent/magnetURI import not enabled 1471 description: HTTP or Torrent/magnetURI import not enabled
1472 '400': 1472 '400':
1473 description: '`magnetUri` or `targetUrl` or a torrent file missing' 1473 description: '`magnetUri` or `targetUrl` or a torrent file missing'
1474 /videos/abuse: 1474 /abuses:
1475 get: 1475 get:
1476 deprecated: true 1476 summary: List abuses
1477 summary: List video abuses
1478 security: 1477 security:
1479 - OAuth2: 1478 - OAuth2:
1480 - admin 1479 - admin
1481 - moderator 1480 - moderator
1482 tags: 1481 tags:
1483 - Video Abuses 1482 - Abuses
1484 parameters: 1483 parameters:
1485 - name: id 1484 - name: id
1486 in: query 1485 in: query
@@ -1491,16 +1490,7 @@ paths:
1491 in: query 1490 in: query
1492 description: predefined reason the listed reports should contain 1491 description: predefined reason the listed reports should contain
1493 schema: 1492 schema:
1494 type: string 1493 $ref: '#/components/schemas/PredefinedAbuseReasons'
1495 enum:
1496 - violentOrAbusive
1497 - hatefulOrAbusive
1498 - spamOrMisleading
1499 - privacy
1500 - rights
1501 - serverRules
1502 - thumbnails
1503 - captions
1504 - name: search 1494 - name: search
1505 in: query 1495 in: query
1506 description: plain search that will match with video titles, reporter names and more 1496 description: plain search that will match with video titles, reporter names and more
@@ -1508,7 +1498,7 @@ paths:
1508 type: string 1498 type: string
1509 - name: state 1499 - name: state
1510 in: query 1500 in: query
1511 description: 'The video playlist privacy (Pending = `1`, Rejected = `2`, Accepted = `3`)' 1501 description: 'The abuse state (Pending = `1`, Rejected = `2`, Accepted = `3`)'
1512 schema: 1502 schema:
1513 type: integer 1503 type: integer
1514 enum: 1504 enum:
@@ -1535,6 +1525,23 @@ paths:
1535 description: only list reports of a specific video channel 1525 description: only list reports of a specific video channel
1536 schema: 1526 schema:
1537 type: string 1527 type: string
1528 - name: videoIs
1529 in: query
1530 description: only list blacklisted or deleted videos
1531 schema:
1532 type: string
1533 enum:
1534 - 'deleted'
1535 - 'blacklisted'
1536 - name: filter
1537 in: query
1538 description: only list account, comment or video reports
1539 schema:
1540 type: string
1541 enum:
1542 - 'video'
1543 - 'comment'
1544 - 'account'
1538 - $ref: '#/components/parameters/start' 1545 - $ref: '#/components/parameters/start'
1539 - $ref: '#/components/parameters/count' 1546 - $ref: '#/components/parameters/count'
1540 - $ref: '#/components/parameters/abusesSort' 1547 - $ref: '#/components/parameters/abusesSort'
@@ -1547,17 +1554,13 @@ paths:
1547 type: array 1554 type: array
1548 items: 1555 items:
1549 $ref: '#/components/schemas/VideoAbuse' 1556 $ref: '#/components/schemas/VideoAbuse'
1550 '/videos/{id}/abuse': 1557
1551 post: 1558 post:
1552 deprecated: true
1553 summary: Report an abuse 1559 summary: Report an abuse
1554 security: 1560 security:
1555 - OAuth2: [] 1561 - OAuth2: []
1556 tags: 1562 tags:
1557 - Video Abuses 1563 - Abuses
1558 - Videos
1559 parameters:
1560 - $ref: '#/components/parameters/idOrUUID'
1561 requestBody: 1564 requestBody:
1562 required: true 1565 required: true
1563 content: 1566 content:
@@ -1570,27 +1573,34 @@ paths:
1570 type: string 1573 type: string
1571 minLength: 4 1574 minLength: 4
1572 predefinedReasons: 1575 predefinedReasons:
1573 description: Reason categories that help triage reports 1576 $ref: '#/components/schemas/PredefinedAbuseReasons'
1574 type: array 1577
1575 items: 1578 video:
1576 type: string 1579 type: object
1577 enum: 1580 properties:
1578 - violentOrAbusive 1581 id:
1579 - hatefulOrAbusive 1582 description: Video id to report
1580 - spamOrMisleading 1583 type: number
1581 - privacy 1584 startAt:
1582 - rights 1585 type: integer
1583 - serverRules 1586 description: Timestamp in the video that marks the beginning of the report
1584 - thumbnails 1587 minimum: 0
1585 - captions 1588 endAt:
1586 startAt: 1589 type: integer
1587 type: integer 1590 description: Timestamp in the video that marks the ending of the report
1588 description: Timestamp in the video that marks the beginning of the report 1591 minimum: 0
1589 minimum: 0 1592 comment:
1590 endAt: 1593 type: object
1591 type: integer 1594 properties:
1592 description: Timestamp in the video that marks the ending of the report 1595 id:
1593 minimum: 0 1596 description: Comment id to report
1597 type: number
1598 account:
1599 type: object
1600 properties:
1601 id:
1602 description: Account id to report
1603 type: number
1594 required: 1604 required:
1595 - reason 1605 - reason
1596 responses: 1606 responses:
@@ -1598,18 +1608,16 @@ paths:
1598 description: successful operation 1608 description: successful operation
1599 '400': 1609 '400':
1600 description: incorrect request parameters 1610 description: incorrect request parameters
1601 '/videos/{id}/abuse/{abuseId}': 1611 '/abuses/{abuseId}':
1602 put: 1612 put:
1603 deprecated: true
1604 summary: Update an abuse 1613 summary: Update an abuse
1605 security: 1614 security:
1606 - OAuth2: 1615 - OAuth2:
1607 - admin 1616 - admin
1608 - moderator 1617 - moderator
1609 tags: 1618 tags:
1610 - Video Abuses 1619 - Abuses
1611 parameters: 1620 parameters:
1612 - $ref: '#/components/parameters/idOrUUID'
1613 - $ref: '#/components/parameters/abuseId' 1621 - $ref: '#/components/parameters/abuseId'
1614 requestBody: 1622 requestBody:
1615 content: 1623 content:
@@ -1618,7 +1626,7 @@ paths:
1618 type: object 1626 type: object
1619 properties: 1627 properties:
1620 state: 1628 state:
1621 $ref: '#/components/schemas/VideoAbuseStateSet' 1629 $ref: '#/components/schemas/AbuseStateSet'
1622 moderationComment: 1630 moderationComment:
1623 type: string 1631 type: string
1624 description: Update the report comment visible only to the moderation team 1632 description: Update the report comment visible only to the moderation team
@@ -1626,18 +1634,16 @@ paths:
1626 '204': 1634 '204':
1627 description: successful operation 1635 description: successful operation
1628 '404': 1636 '404':
1629 description: video abuse not found 1637 description: abuse not found
1630 delete: 1638 delete:
1631 deprecated: true
1632 tags: 1639 tags:
1633 - Video Abuses 1640 - Abuses
1634 summary: Delete an abuse 1641 summary: Delete an abuse
1635 security: 1642 security:
1636 - OAuth2: 1643 - OAuth2:
1637 - admin 1644 - admin
1638 - moderator 1645 - moderator
1639 parameters: 1646 parameters:
1640 - $ref: '#/components/parameters/idOrUUID'
1641 - $ref: '#/components/parameters/abuseId' 1647 - $ref: '#/components/parameters/abuseId'
1642 responses: 1648 responses:
1643 '204': 1649 '204':
@@ -3320,7 +3326,7 @@ components:
3320 name: abuseId 3326 name: abuseId
3321 in: path 3327 in: path
3322 required: true 3328 required: true
3323 description: Video abuse id 3329 description: Abuse id
3324 schema: 3330 schema:
3325 type: integer 3331 type: integer
3326 captionLanguage: 3332 captionLanguage:
@@ -3584,20 +3590,20 @@ components:
3584 label: 3590 label:
3585 type: string 3591 type: string
3586 3592
3587 VideoAbuseStateSet: 3593 AbuseStateSet:
3588 type: integer 3594 type: integer
3589 enum: 3595 enum:
3590 - 1 3596 - 1
3591 - 2 3597 - 2
3592 - 3 3598 - 3
3593 description: 'The video playlist privacy (Pending = `1`, Rejected = `2`, Accepted = `3`)' 3599 description: 'The video playlist privacy (Pending = `1`, Rejected = `2`, Accepted = `3`)'
3594 VideoAbuseStateConstant: 3600 AbuseStateConstant:
3595 properties: 3601 properties:
3596 id: 3602 id:
3597 $ref: '#/components/schemas/VideoAbuseStateSet' 3603 $ref: '#/components/schemas/AbuseStateSet'
3598 label: 3604 label:
3599 type: string 3605 type: string
3600 VideoAbusePredefinedReasons: 3606 AbusePredefinedReasons:
3601 type: array 3607 type: array
3602 items: 3608 items:
3603 type: string 3609 type: string
@@ -3960,11 +3966,11 @@ components:
3960 type: string 3966 type: string
3961 example: The video is a spam 3967 example: The video is a spam
3962 predefinedReasons: 3968 predefinedReasons:
3963 $ref: '#/components/schemas/VideoAbusePredefinedReasons' 3969 $ref: '#/components/schemas/AbusePredefinedReasons'
3964 reporterAccount: 3970 reporterAccount:
3965 $ref: '#/components/schemas/Account' 3971 $ref: '#/components/schemas/Account'
3966 state: 3972 state:
3967 $ref: '#/components/schemas/VideoAbuseStateConstant' 3973 $ref: '#/components/schemas/AbuseStateConstant'
3968 moderationComment: 3974 moderationComment:
3969 type: string 3975 type: string
3970 example: Decided to ban the server since it spams us regularly 3976 example: Decided to ban the server since it spams us regularly
@@ -4553,6 +4559,22 @@ components:
4553 updatedAt: 4559 updatedAt:
4554 type: string 4560 type: string
4555 format: date-time 4561 format: date-time
4562
4563 PredefinedAbuseReasons:
4564 description: Reason categories that help triage reports
4565 type: array
4566 items:
4567 type: string
4568 enum:
4569 - violentOrAbusive
4570 - hatefulOrAbusive
4571 - spamOrMisleading
4572 - privacy
4573 - rights
4574 - serverRules
4575 - thumbnails
4576 - captions
4577
4556 Job: 4578 Job:
4557 properties: 4579 properties:
4558 id: 4580 id:
@@ -4690,11 +4712,11 @@ components:
4690 description: The user daily video quota 4712 description: The user daily video quota
4691 videosCount: 4713 videosCount:
4692 type: integer 4714 type: integer
4693 videoAbusesCount: 4715 abusesCount:
4694 type: integer 4716 type: integer
4695 videoAbusesAcceptedCount: 4717 abusesAcceptedCount:
4696 type: integer 4718 type: integer
4697 videoAbusesCreatedCount: 4719 abusesCreatedCount:
4698 type: integer 4720 type: integer
4699 videoCommentsCount: 4721 videoCommentsCount:
4700 type: integer 4722 type: integer
@@ -5098,7 +5120,7 @@ components:
5098 5120
5099 - `2` NEW_COMMENT_ON_MY_VIDEO 5121 - `2` NEW_COMMENT_ON_MY_VIDEO
5100 5122
5101 - `3` NEW_VIDEO_ABUSE_FOR_MODERATORS 5123 - `3` NEW_ABUSE_FOR_MODERATORS
5102 5124
5103 - `4` BLACKLIST_ON_MY_VIDEO 5125 - `4` BLACKLIST_ON_MY_VIDEO
5104 5126