]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'release/2.3.0' into develop
authorChocobozzz <me@florianbigard.com>
Fri, 10 Jul 2020 13:23:31 +0000 (15:23 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 10 Jul 2020 13:23:31 +0000 (15:23 +0200)
162 files changed:
.github/CONTRIBUTING.md
client/src/app/+accounts/accounts.component.html
client/src/app/+accounts/accounts.component.ts
client/src/app/+admin/admin.component.ts
client/src/app/+admin/admin.module.ts
client/src/app/+admin/moderation/abuse-list/abuse-details.component.html [new file with mode: 0644]
client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts [moved from client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts with 59% similarity]
client/src/app/+admin/moderation/abuse-list/abuse-list.component.html [new file with mode: 0644]
client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss [moved from client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss with 88% similarity]
client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts [new file with mode: 0644]
client/src/app/+admin/moderation/abuse-list/index.ts [new file with mode: 0644]
client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html [moved from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html with 100% similarity]
client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss [moved from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.scss with 100% similarity]
client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts [moved from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts with 73% similarity]
client/src/app/+admin/moderation/index.ts
client/src/app/+admin/moderation/moderation.component.scss
client/src/app/+admin/moderation/moderation.routes.ts
client/src/app/+admin/moderation/video-abuse-list/index.ts [deleted file]
client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html [deleted file]
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html [deleted file]
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts [deleted file]
client/src/app/+admin/users/user-edit/user-edit.component.html
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts
client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
client/src/app/+videos/+video-watch/comment/video-comment.component.html
client/src/app/+videos/+video-watch/comment/video-comment.component.ts
client/src/app/+videos/+video-watch/comment/video-comments.component.ts
client/src/app/+videos/+video-watch/video-watch.module.ts
client/src/app/core/rest/rest-table.ts
client/src/app/core/users/user.model.ts
client/src/app/menu/menu.component.ts
client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts [moved from client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts with 81% similarity]
client/src/app/shared/shared-forms/form-validators/index.ts
client/src/app/shared/shared-forms/shared-form.module.ts
client/src/app/shared/shared-main/account/actor.model.ts
client/src/app/shared/shared-main/users/user-notification.model.ts
client/src/app/shared/shared-main/users/user-notifications.component.html
client/src/app/shared/shared-moderation/abuse.service.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/index.ts
client/src/app/shared/shared-moderation/report-modals/account-report.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/report-modals/index.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/report-modals/report.component.html [new file with mode: 0644]
client/src/app/shared/shared-moderation/report-modals/report.component.scss [moved from client/src/app/shared/shared-moderation/video-report.component.scss with 100% similarity]
client/src/app/shared/shared-moderation/report-modals/video-report.component.html [moved from client/src/app/shared/shared-moderation/video-report.component.html with 92% similarity]
client/src/app/shared/shared-moderation/report-modals/video-report.component.ts [new file with mode: 0644]
client/src/app/shared/shared-moderation/shared-moderation.module.ts
client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
client/src/app/shared/shared-moderation/video-abuse.service.ts [deleted file]
client/src/app/shared/shared-moderation/video-report.component.ts [deleted file]
client/src/app/shared/shared-video-comment/index.ts [new file with mode: 0644]
client/src/app/shared/shared-video-comment/shared-video-comment.module.ts [new file with mode: 0644]
client/src/app/shared/shared-video-comment/video-comment-thread-tree.model.ts [moved from client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts with 100% similarity]
client/src/app/shared/shared-video-comment/video-comment.model.ts [moved from client/src/app/+videos/+video-watch/comment/video-comment.model.ts with 100% similarity]
client/src/app/shared/shared-video-comment/video-comment.service.ts [moved from client/src/app/+videos/+video-watch/comment/video-comment.service.ts with 98% similarity]
server/controllers/api/abuse.ts [new file with mode: 0644]
server/controllers/api/index.ts
server/controllers/api/users/my-history.ts
server/controllers/api/users/my-notifications.ts
server/controllers/api/videos/abuse.ts
server/helpers/audit-logger.ts
server/helpers/custom-validators/abuses.ts [new file with mode: 0644]
server/helpers/custom-validators/activitypub/flag.ts
server/helpers/custom-validators/video-abuses.ts [deleted file]
server/helpers/custom-validators/video-comments.ts
server/helpers/middlewares/abuses.ts [new file with mode: 0644]
server/helpers/middlewares/accounts.ts
server/helpers/middlewares/index.ts
server/helpers/middlewares/video-abuses.ts [deleted file]
server/initializers/constants.ts
server/initializers/database.ts
server/initializers/migrations/0250-video-abuse-state.ts
server/initializers/migrations/0470-cleanup-indexes.ts [moved from server/initializers/migrations/0470-cleaup-indexes.ts with 100% similarity]
server/initializers/migrations/0520-abuses-split.ts [new file with mode: 0644]
server/lib/activitypub/process/process-flag.ts
server/lib/activitypub/send/send-flag.ts
server/lib/activitypub/url.ts
server/lib/emailer.ts
server/lib/emails/account-abuse-new/html.pug [new file with mode: 0644]
server/lib/emails/common/mixins.pug
server/lib/emails/video-abuse-new/html.pug
server/lib/emails/video-comment-abuse-new/html.pug [new file with mode: 0644]
server/lib/moderation.ts
server/lib/notifier.ts
server/lib/user.ts
server/middlewares/validators/abuse.ts [new file with mode: 0644]
server/middlewares/validators/index.ts
server/middlewares/validators/sort.ts
server/middlewares/validators/user-notifications.ts
server/middlewares/validators/videos/index.ts
server/middlewares/validators/videos/video-abuses.ts [deleted file]
server/middlewares/validators/videos/video-comments.ts
server/models/abuse/abuse-query-builder.ts [new file with mode: 0644]
server/models/abuse/abuse.ts [new file with mode: 0644]
server/models/abuse/video-abuse.ts [new file with mode: 0644]
server/models/abuse/video-comment-abuse.ts [new file with mode: 0644]
server/models/account/account-blocklist.ts
server/models/account/account.ts
server/models/account/user-notification-setting.ts
server/models/account/user-notification.ts
server/models/account/user.ts
server/models/server/server-blocklist.ts
server/models/video/video-abuse.ts [deleted file]
server/models/video/video-channel.ts
server/models/video/video-comment.ts
server/models/video/video-query-builder.ts
server/models/video/video.ts
server/tests/api/check-params/abuses.ts [new file with mode: 0644]
server/tests/api/check-params/index.ts
server/tests/api/check-params/user-notifications.ts
server/tests/api/check-params/video-abuses.ts
server/tests/api/ci-4.sh
server/tests/api/index.ts
server/tests/api/moderation/abuses.ts [new file with mode: 0644]
server/tests/api/moderation/blocklist.ts [moved from server/tests/api/users/blocklist.ts with 100% similarity]
server/tests/api/moderation/index.ts [new file with mode: 0644]
server/tests/api/notifications/moderation-notifications.ts
server/tests/api/server/email.ts
server/tests/api/users/index.ts
server/tests/api/users/users.ts
server/tests/api/videos/video-abuse.ts
server/types/models/index.ts
server/types/models/moderation/abuse.ts [new file with mode: 0644]
server/types/models/moderation/index.ts [new file with mode: 0644]
server/types/models/user/user-notification.ts
server/types/models/video/index.ts
server/types/models/video/video-abuse.ts [deleted file]
server/typings/express/index.d.ts
shared/extra-utils/index.ts
shared/extra-utils/moderation/abuses.ts [new file with mode: 0644]
shared/extra-utils/server/servers.ts
shared/extra-utils/users/user-notifications.ts
shared/extra-utils/videos/video-abuses.ts
shared/models/activitypub/activity.ts
shared/models/activitypub/objects/abuse-object.ts [moved from shared/models/activitypub/objects/video-abuse-object.ts with 84% similarity]
shared/models/activitypub/objects/common-objects.ts
shared/models/activitypub/objects/index.ts
shared/models/index.ts
shared/models/moderation/abuse/abuse-create.model.ts [new file with mode: 0644]
shared/models/moderation/abuse/abuse-filter.type.ts [new file with mode: 0644]
shared/models/moderation/abuse/abuse-reason.model.ts [new file with mode: 0644]
shared/models/moderation/abuse/abuse-state.model.ts [moved from shared/models/videos/abuse/video-abuse-state.model.ts with 61% similarity]
shared/models/moderation/abuse/abuse-update.model.ts [new file with mode: 0644]
shared/models/moderation/abuse/abuse-video-is.type.ts [new file with mode: 0644]
shared/models/moderation/abuse/abuse.model.ts [new file with mode: 0644]
shared/models/moderation/abuse/index.ts [new file with mode: 0644]
shared/models/moderation/account-block.model.ts [moved from shared/models/blocklist/account-block.model.ts with 100% similarity]
shared/models/moderation/index.ts [moved from shared/models/blocklist/index.ts with 75% similarity]
shared/models/moderation/server-block.model.ts [moved from shared/models/blocklist/server-block.model.ts with 100% similarity]
shared/models/users/user-notification-setting.model.ts
shared/models/users/user-notification.model.ts
shared/models/users/user-right.enum.ts
shared/models/users/user-role.ts
shared/models/users/user.model.ts
shared/models/videos/abuse/index.ts [deleted file]
shared/models/videos/abuse/video-abuse-create.model.ts [deleted file]
shared/models/videos/abuse/video-abuse-reason.model.ts [deleted file]
shared/models/videos/abuse/video-abuse-update.model.ts [deleted file]
shared/models/videos/abuse/video-abuse-video-is.type.ts [deleted file]
shared/models/videos/abuse/video-abuse.model.ts [deleted file]
shared/models/videos/index.ts
support/doc/api/openapi.yaml

index b12e973617e963cd4e0c0069721e46b25707c974..704b22b8b52b86048253b030bd6c0e4118a48fc3 100644 (file)
@@ -4,12 +4,26 @@ Interested in contributing? Awesome!
 
 **This guide will present you the following contribution topics:**
 
-  * [Translate](#translate)
-  * [Give your feedback](#give-your-feedback)
-  * [Write documentation](#write-documentation)
-  * [Improve the website](#improve-the-website)
-  * [Develop](#develop)
-  * [Write a plugin or a theme](#plugins--themes)
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+
+
+- [Translate](#translate)
+- [Give your feedback](#give-your-feedback)
+- [Write documentation](#write-documentation)
+- [Improve the website](#improve-the-website)
+- [Develop](#develop)
+  - [Prerequisites](#prerequisites)
+  - [Online development](#online-development)
+  - [Server side](#server-side)
+  - [Client side](#client-side)
+  - [Client and server side](#client-and-server-side)
+  - [Testing the federation of PeerTube servers](#testing-the-federation-of-peertube-servers)
+  - [Unit tests](#unit-tests)
+  - [Emails](#emails)
+- [Plugins & Themes](#plugins--themes)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
 
 ## Translate
 
@@ -30,7 +44,7 @@ You can help to write the documentation of the REST API, code, architecture,
 demonstrations.
 
 For the REST API you can see the documentation in [/support/doc/api](https://github.com/Chocobozzz/PeerTube/tree/develop/support/doc/api) directory.
-Then, 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.
+Then, 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.
 
 Some hints:
  * 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
 Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
 Note that only instance 2 has transcoding enabled.
 
+### Emails
+
+To test emails with PeerTube:
+
+ * Run [mailslurper](http://mailslurper.com/)
+ * Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=test npm start`
+
 ## Plugins & Themes
 
 See the dedicated documentation: https://docs.joinpeertube.org/#/contribute-plugins
index af80337ce84dc780e7ce979e35f9e0f7a7b414bf..31c8e3a8ec498881a96dd2f9aabeb9031573db3e 100644 (file)
@@ -22,6 +22,7 @@
           <span *ngIf="account.mutedServerByInstance" class="badge badge-danger" i18n>Instance muted by your instance</span>
 
           <my-user-moderation-dropdown
+            [prependActions]="prependModerationActions"
             buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto"
             (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
           ></my-user-moderation-dropdown>
@@ -50,3 +51,7 @@
     <router-outlet></router-outlet>
   </div>
 </div>
+
+<ng-container *ngIf="prependModerationActions">
+  <my-account-report #accountReportModal [account]="account"></my-account-report>
+</ng-container>
index 01911cac286892648079c6dc1a38d6f5820a5bd7..9288fcb42bfbe0fcc0415705b5d7da47e1031450 100644 (file)
@@ -1,9 +1,10 @@
 import { Subscription } from 'rxjs'
 import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
-import { Component, OnDestroy, OnInit } from '@angular/core'
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
-import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { AccountReportComponent } from '@app/shared/shared-moderation'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { User, UserRight } from '@shared/models'
 
@@ -12,6 +13,8 @@ import { User, UserRight } from '@shared/models'
   styleUrls: [ './accounts.component.scss' ]
 })
 export class AccountsComponent implements OnInit, OnDestroy {
+  @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
+
   account: Account
   accountUser: User
   videoChannels: VideoChannel[] = []
@@ -20,6 +23,8 @@ export class AccountsComponent implements OnInit, OnDestroy {
   isAccountManageable = false
   accountFollowerTitle = ''
 
+  prependModerationActions: DropdownAction<any>[]
+
   private routeSub: Subscription
 
   constructor (
@@ -42,24 +47,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
                           map(params => params[ 'accountId' ]),
                           distinctUntilChanged(),
                           switchMap(accountId => this.accountService.getAccount(accountId)),
-                          tap(account => {
-                            this.account = account
-
-                            if (this.authService.isLoggedIn()) {
-                              this.authService.userInformationLoaded.subscribe(
-                                () => {
-                                  this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
-
-                                  this.accountFollowerTitle = this.i18n(
-                                    '{{followers}} direct account followers',
-                                    { followers: this.subscribersDisplayFor(account.followersCount) }
-                                  )
-                                }
-                              )
-                            }
-
-                            this.getUserIfNeeded(account)
-                          }),
+                          tap(account => this.onAccount(account)),
                           switchMap(account => this.videoChannelService.listAccountVideoChannels(account)),
                           catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
                         )
@@ -107,6 +95,41 @@ export class AccountsComponent implements OnInit, OnDestroy {
     return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count })
   }
 
+  private onAccount (account: Account) {
+    this.prependModerationActions = undefined
+
+    this.account = account
+
+    if (this.authService.isLoggedIn()) {
+      this.authService.userInformationLoaded.subscribe(
+        () => {
+          this.isAccountManageable = this.account.userId && this.account.userId === this.authService.getUser().id
+
+          this.accountFollowerTitle = this.i18n(
+            '{{followers}} direct account followers',
+            { followers: this.subscribersDisplayFor(account.followersCount) }
+          )
+
+          // It's not our account, we can report it
+          if (!this.isAccountManageable) {
+            this.prependModerationActions = [
+              {
+                label: this.i18n('Report account'),
+                handler: () => this.showReportModal()
+              }
+            ]
+          }
+        }
+      )
+    }
+
+    this.getUserIfNeeded(account)
+  }
+
+  private showReportModal () {
+    this.accountReportModal.show()
+  }
+
   private getUserIfNeeded (account: Account) {
     if (!account.userId || !this.authService.isLoggedIn()) return
 
index 6f340884f8d9e0e39360dcb7126543a964a1cbff..4345d1945e6567aa6462ee39f2fb5a0399d9258d 100644 (file)
@@ -45,10 +45,10 @@ export class AdminComponent implements OnInit {
       children: []
     }
 
-    if (this.hasVideoAbusesRight()) {
+    if (this.hasAbusesRight()) {
       moderationItems.children.push({
-        label: this.i18n('Video reports'),
-        routerLink: '/admin/moderation/video-abuses/list',
+        label: this.i18n('Reports'),
+        routerLink: '/admin/moderation/abuses/list',
         iconName: 'flag'
       })
     }
@@ -76,7 +76,7 @@ export class AdminComponent implements OnInit {
 
     if (this.hasUsersRight()) this.menuEntries.push({ label: this.i18n('Users'), routerLink: '/admin/users' })
     if (this.hasServerFollowRight()) this.menuEntries.push(federationItems)
-    if (this.hasVideoAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems)
+    if (this.hasAbusesRight() || this.hasVideoBlocklistRight()) this.menuEntries.push(moderationItems)
     if (this.hasConfigRight()) this.menuEntries.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' })
     if (this.hasPluginsRight()) this.menuEntries.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' })
     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 {
     return this.auth.getUser().hasRight(UserRight.MANAGE_SERVER_FOLLOW)
   }
 
-  hasVideoAbusesRight () {
-    return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES)
+  hasAbusesRight () {
+    return this.auth.getUser().hasRight(UserRight.MANAGE_ABUSES)
   }
 
   hasVideoBlocklistRight () {
index 728227a84d076d48ff12bd225e2c56d3988fbc50..c59bd292752aa74a5b87fe8e0a912cb672bc060f 100644 (file)
@@ -14,10 +14,10 @@ import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponen
 import { FollowingListComponent } from './follows/following-list/following-list.component'
 import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
 import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
-import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlockListComponent } from './moderation'
+import { ModerationCommentModalComponent, AbuseListComponent, VideoBlockListComponent } from './moderation'
 import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
 import { ModerationComponent } from './moderation/moderation.component'
-import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component'
+import { AbuseDetailsComponent } from './moderation/abuse-list/abuse-details.component'
 import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
 import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
 import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
@@ -60,8 +60,10 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
 
     ModerationComponent,
     VideoBlockListComponent,
-    VideoAbuseListComponent,
-    VideoAbuseDetailsComponent,
+
+    AbuseListComponent,
+    AbuseDetailsComponent,
+
     ModerationCommentModalComponent,
     InstanceServerBlocklistComponent,
     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 (file)
index 0000000..cba9cfb
--- /dev/null
@@ -0,0 +1,115 @@
+<div class="d-flex moderation-expanded">
+  <!-- report left part (report details) -->
+  <div class="col-8">
+
+    <!-- report metadata -->
+    <div class="d-flex" *ngIf="abuse.reporterAccount">
+      <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
+
+      <span class="col-9 moderation-expanded-text">
+        <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
+          class="chip"
+        >
+          <img
+            class="avatar"
+            [src]="abuse.reporterAccount.avatar?.path"
+            (error)="switchToDefaultAvatar($event)"
+            alt="Avatar"
+          >
+          <div>
+            <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
+          </div>
+        </a>
+
+        <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
+          class="ml-auto text-muted abuse-details-links" i18n
+        >
+          {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
+        </a>
+      </span>
+    </div>
+
+    <div class="d-flex" *ngIf="abuse.flaggedAccount">
+      <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
+      <span class="col-9 moderation-expanded-text">
+        <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
+          class="chip"
+        >
+          <img
+            class="avatar"
+            [src]="abuse.flaggedAccount?.avatar?.path"
+            (error)="switchToDefaultAvatar($event)"
+            alt="Avatar"
+          >
+          <div>
+            <span class="text-muted">{{ abuse.flaggedAccount ? abuse.flaggedAccount.nameWithHost : '' }}</span>
+          </div>
+        </a>
+
+        <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
+          class="ml-auto text-muted abuse-details-links" i18n
+        >
+          {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
+        </a>
+      </span>
+    </div>
+
+    <div class="d-flex" *ngIf="abuse.updatedAt">
+      <span class="col-3 moderation-expanded-label" i18n>Updated</span>
+      <time class="col-9 moderation-expanded-text abuse-details-date-updated">{{ abuse.updatedAt | date: 'medium' }}</time>
+    </div>
+
+    <!-- report text -->
+    <div class="mt-3 d-flex">
+      <span class="col-3 moderation-expanded-label">
+        <ng-container i18n>Report</ng-container>
+        <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': '#' + abuse.id  }" class="ml-1 text-muted">#{{ abuse.id }}</a>
+      </span>
+      <span class="col-9 moderation-expanded-text" [innerHTML]="abuse.reasonHtml"></span>
+    </div>
+
+    <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
+      <span class="col-3"></span>
+      <span class="col-9">
+        <a *ngFor="let reason of getPredefinedReasons()"  [routerLink]="[ '/admin/moderation/abuses/list' ]"
+          [queryParams]="{ 'search': 'tag:' + reason.id  }" class="chip rectangular bg-secondary text-light"
+        >
+          <div>{{ reason.label }}</div>
+        </a>
+      </span>
+    </div>
+
+    <div *ngIf="abuse.video?.startAt" class="mt-2 d-flex">
+      <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
+      <span class="col-9">
+        {{ startAt }}<ng-container *ngIf="abuse.video.endAt"> - {{ endAt }}</ng-container>
+      </span>
+    </div>
+
+    <div class="mt-3 d-flex" *ngIf="abuse.moderationComment">
+      <span class="col-3 moderation-expanded-label" i18n>Note</span>
+      <span class="col-9 moderation-expanded-text d-block" [innerHTML]="abuse.moderationCommentHtml"></span>
+    </div>
+
+  </div>
+
+  <!-- report right part (video/comment details) -->
+  <div class="col-4">
+    <div *ngIf="abuse.video" class="screenratio">
+      <div>
+        <span i18n *ngIf="abuse.video.deleted">The video was deleted</span>
+        <span i18n *ngIf="!abuse.video.deleted">The video was blocked</span>
+      </div>
+
+      <div *ngIf="!abuse.video.deleted && !abuse.video.blacklisted" [innerHTML]="abuse.embedHtml"></div>
+    </div>
+
+    <div *ngIf="abuse.comment" class="comment-html">
+      <div>
+        <strong i18n>Comment:</strong>
+      </div>
+
+      <div [innerHTML]="abuse.commentHtml"></div>
+    </div>
+  </div>
+</div>
similarity index 59%
rename from client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
rename to client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts
index 5db2887fa8bd7ef48ee83d5400f8e900d8922fbf..fb0f65764480aace6332623ec5cb093ee629ee46 100644 (file)
@@ -1,19 +1,19 @@
 import { Component, Input } from '@angular/core'
 import { Actor } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
-import { ProcessedVideoAbuse } from './video-abuse-list.component'
+import { AbusePredefinedReasonsString } from '@shared/models'
+import { ProcessedAbuse } from './abuse-list.component'
 import { durationToString } from '@app/helpers'
 
 @Component({
-  selector: 'my-video-abuse-details',
-  templateUrl: './video-abuse-details.component.html',
+  selector: 'my-abuse-details',
+  templateUrl: './abuse-details.component.html',
   styleUrls: [ '../moderation.component.scss' ]
 })
-export class VideoAbuseDetailsComponent {
-  @Input() videoAbuse: ProcessedVideoAbuse
+export class AbuseDetailsComponent {
+  @Input() abuse: ProcessedAbuse
 
-  private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string }
+  private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }
 
   constructor (
     private i18n: I18n
@@ -31,16 +31,17 @@ export class VideoAbuseDetailsComponent {
   }
 
   get startAt () {
-    return durationToString(this.videoAbuse.startAt)
+    return durationToString(this.abuse.video.startAt)
   }
 
   get endAt () {
-    return durationToString(this.videoAbuse.endAt)
+    return durationToString(this.abuse.video.endAt)
   }
 
   getPredefinedReasons () {
-    if (!this.videoAbuse.predefinedReasons) return []
-    return this.videoAbuse.predefinedReasons.map(r => ({
+    if (!this.abuse.predefinedReasons) return []
+
+    return this.abuse.predefinedReasons.map(r => ({
       id: r,
       label: this.predefinedReasonsTranslations[r]
     }))
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 (file)
index 0000000..9950230
--- /dev/null
@@ -0,0 +1,184 @@
+<p-table
+  [value]="abuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
+  [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" [lazyLoadOnInit]="false"
+  [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+  currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
+  (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div class="ml-auto">
+        <div class="input-group has-feedback has-clear">
+          <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
+            <div class="input-group-text" ngbDropdownToggle>
+              <span class="caret" aria-haspopup="menu" role="button"></span>
+            </div>
+
+            <div role="menu" ngbDropdownMenu>
+              <h6 class="dropdown-header" i18n>Advanced report filters</h6>
+              <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
+              <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
+              <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
+              <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
+              <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
+            </div>
+          </div>
+          <input
+            type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+            (keyup)="onAbuseSearch($event)"
+          >
+          <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
+          <span class="sr-only" i18n>Clear filters</span>
+        </div>
+      </div>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr> <!-- header -->
+      <th style="width: 40px;"></th>
+      <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
+      <th i18n>Video/Comment/Account</th>
+      <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
+      <th style="width: 150px;"></th>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-expanded="expanded" let-abuse>
+    <tr>
+      <td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
+        <span class="expander">
+          <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
+        </span>
+      </td>
+
+      <td>
+        <a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
+          <div class="chip two-lines">
+            <img
+              class="avatar"
+              [src]="abuse.reporterAccount.avatar?.path"
+              (error)="switchToDefaultAvatar($event)"
+              alt="Avatar"
+            >
+            <div>
+              {{ abuse.reporterAccount.displayName }}
+              <span>{{ abuse.reporterAccount.nameWithHost }}</span>
+            </div>
+          </div>
+        </a>
+
+        <span i18n *ngIf="!abuse.reporterAccount">
+          Deleted account
+        </span>
+      </td>
+
+      <ng-container *ngIf="abuse.video">
+
+        <td *ngIf="!abuse.video.deleted">
+          <a [href]="getVideoUrl(abuse)" class="table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
+            <div class="table-video">
+              <div class="table-video-image">
+                <img [src]="abuse.video.thumbnailPath">
+                <span
+                  class="table-video-image-label" *ngIf="abuse.count > 1"
+                  i18n-title title="This video has been reported multiple times."
+                >
+                  {{ abuse.nth }}/{{ abuse.count }}
+                </span>
+              </div>
+
+              <div class="table-video-text">
+                <div>
+                  <span *ngIf="!abuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
+                  <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
+                  {{ abuse.video.name }}
+                </div>
+                <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
+              </div>
+            </div>
+          </a>
+        </td>
+
+        <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
+          <div class="table-video" i18n-title title="Video was deleted">
+            <div class="table-video-image">
+              <span i18n>Deleted</span>
+            </div>
+
+            <div class="table-video-text">
+              <div>
+                {{ abuse.video.name }}
+                <span class="glyphicon glyphicon-trash"></span>
+              </div>
+              <div i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
+            </div>
+          </div>
+        </td>
+      </ng-container>
+
+      <ng-container *ngIf="abuse.comment">
+        <td>
+          <a [href]="getCommentUrl(abuse)" [innerHTML]="abuse.truncatedCommentHtml" class="table-comment-link"
+            [title]="abuse.comment.video.name" target="_blank" rel="noopener noreferrer"
+          ></a>
+
+          <div class="comment-flagged-account" *ngIf="abuse.flaggedAccount">by {{ abuse.flaggedAccount.displayName }}</div>
+        </td>
+      </ng-container>
+
+      <ng-container *ngIf="!abuse.comment && !abuse.video">
+        <td *ngIf="abuse.flaggedAccount">
+          <a [href]="getAccountUrl(abuse)"  class="table-account-link" target="_blank" rel="noopener noreferrer">
+            <span>{{ abuse.flaggedAccount.displayName }}</span>
+
+            <span class="account-flagged-handle">{{ abuse.flaggedAccount.nameWithHostForced }}</span>
+          </a>
+        </td>
+
+        <td i18n *ngIf="!abuse.flaggedAccount">
+          Account deleted
+        </td>
+
+      </ng-container>
+
+
+      <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short'  }}</td>
+
+      <td class="c-hand abuse-states" [pRowToggler]="abuse">
+        <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
+        <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
+        <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
+      </td>
+
+      <td class="action-cell">
+        <my-action-dropdown
+          [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
+          i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
+        ></my-action-dropdown>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-abuse>
+      <tr>
+        <td class="expand-cell" colspan="6">
+          <my-abuse-details [abuse]="abuse"></my-abuse-details>
+        </td>
+      </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td colspan="6">
+        <div class="no-results">
+          <ng-container *ngIf="search" i18n>No abuses found matching current filters.</ng-container>
+          <ng-container *ngIf="!search" i18n>No abuses found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
+<my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal>
similarity index 88%
rename from client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
rename to client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss
index 8eee15b64fc16aad60c3ad9cd634ed77fdffdb45..c22f98c4770341c05563b1c997dab70e54c73635 100644 (file)
@@ -10,7 +10,7 @@
   @include disable-default-a-behaviour;
 }
 
-.video-abuse-states .glyphicon-comment {
+.abuse-states .glyphicon-comment {
   margin-left: 0.5rem;
 }
 
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 (file)
index 0000000..74c5fe2
--- /dev/null
@@ -0,0 +1,454 @@
+import * as debug from 'debug'
+import truncate from 'lodash-es/truncate'
+import { SortMeta } from 'primeng/api'
+import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
+import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
+import { ActivatedRoute, Params, Router } from '@angular/router'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
+import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
+import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
+import { VideoCommentService } from '@app/shared/shared-video-comment'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Abuse, AbuseState } from '@shared/models'
+import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
+
+const logger = debug('peertube:moderation:AbuseListComponent')
+
+// Don't use an abuse model because we need external services to compute some properties
+// And this model is only used in this component
+export type ProcessedAbuse = Abuse & {
+  moderationCommentHtml?: string,
+  reasonHtml?: string
+  embedHtml?: SafeHtml
+  updatedAt?: Date
+
+  // override bare server-side definitions with rich client-side definitions
+  reporterAccount?: Account
+  flaggedAccount?: Account
+
+  truncatedCommentHtml?: string
+  commentHtml?: string
+
+  video: Abuse['video'] & {
+    channel: Abuse['video']['channel'] & {
+      ownerAccount: Account
+    }
+  }
+}
+
+@Component({
+  selector: 'my-abuse-list',
+  templateUrl: './abuse-list.component.html',
+  styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ]
+})
+export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
+  @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
+
+  abuses: ProcessedAbuse[] = []
+  totalRecords = 0
+  sort: SortMeta = { field: 'createdAt', order: 1 }
+  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+  abuseActions: DropdownAction<ProcessedAbuse>[][] = []
+
+  constructor (
+    private notifier: Notifier,
+    private abuseService: AbuseService,
+    private blocklistService: BlocklistService,
+    private commentService: VideoCommentService,
+    private videoService: VideoService,
+    private videoBlocklistService: VideoBlockService,
+    private confirmService: ConfirmService,
+    private i18n: I18n,
+    private markdownRenderer: MarkdownService,
+    private sanitizer: DomSanitizer,
+    private route: ActivatedRoute,
+    private router: Router
+  ) {
+    super()
+
+    this.abuseActions = [
+      this.buildInternalActions(),
+
+      this.buildFlaggedAccountActions(),
+
+      this.buildCommentActions(),
+
+      this.buildVideoActions(),
+
+      this.buildAccountActions()
+    ]
+  }
+
+  ngOnInit () {
+    this.initialize()
+
+    this.route.queryParams
+      .subscribe(params => {
+        this.search = params.search || ''
+
+        logger('On URL change (search: %s).', this.search)
+
+        this.setTableFilter(this.search)
+        this.loadData()
+      })
+  }
+
+  ngAfterViewInit () {
+    if (this.search) this.setTableFilter(this.search)
+  }
+
+  getIdentifier () {
+    return 'AbuseListComponent'
+  }
+
+  openModerationCommentModal (abuse: Abuse) {
+    this.moderationCommentModal.openModal(abuse)
+  }
+
+  onModerationCommentUpdated () {
+    this.loadData()
+  }
+
+  /* Table filter functions */
+  onAbuseSearch (event: Event) {
+    this.onSearch(event)
+    this.setQueryParams((event.target as HTMLInputElement).value)
+  }
+
+  setQueryParams (search: string) {
+    const queryParams: Params = {}
+    if (search) Object.assign(queryParams, { search })
+
+    this.router.navigate([ '/admin/moderation/abuses/list' ], { queryParams })
+  }
+
+  resetTableFilter () {
+    this.setTableFilter('')
+    this.setQueryParams('')
+    this.resetSearch()
+  }
+  /* END Table filter functions */
+
+  isAbuseAccepted (abuse: Abuse) {
+    return abuse.state.id === AbuseState.ACCEPTED
+  }
+
+  isAbuseRejected (abuse: Abuse) {
+    return abuse.state.id === AbuseState.REJECTED
+  }
+
+  getVideoUrl (abuse: Abuse) {
+    return Video.buildClientUrl(abuse.video.uuid)
+  }
+
+  getCommentUrl (abuse: Abuse) {
+    return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId
+  }
+
+  getAccountUrl (abuse: ProcessedAbuse) {
+    return '/accounts/' + abuse.flaggedAccount.nameWithHost
+  }
+
+  getVideoEmbed (abuse: Abuse) {
+    return buildVideoEmbed(
+      buildVideoLink({
+        baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
+        title: false,
+        warningTitle: false,
+        startTime: abuse.startAt,
+        stopTime: abuse.endAt
+      })
+    )
+  }
+
+  switchToDefaultAvatar ($event: Event) {
+    ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
+  }
+
+  async removeAbuse (abuse: Abuse) {
+    const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
+    if (res === false) return
+
+    this.abuseService.removeAbuse(abuse).subscribe(
+      () => {
+        this.notifier.success(this.i18n('Abuse deleted.'))
+        this.loadData()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+
+  updateAbuseState (abuse: Abuse, state: AbuseState) {
+    this.abuseService.updateAbuse(abuse, { state })
+      .subscribe(
+        () => this.loadData(),
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  protected loadData () {
+    logger('Load data.')
+
+    return this.abuseService.getAbuses({
+      pagination: this.pagination,
+      sort: this.sort,
+      search: this.search
+    }).subscribe(
+        async resultList => {
+          this.totalRecords = resultList.total
+
+          this.abuses = []
+
+          for (const a of resultList.data) {
+            const abuse = a as ProcessedAbuse
+
+            abuse.reasonHtml = await this.toHtml(abuse.reason)
+            abuse.moderationCommentHtml = await this.toHtml(abuse.moderationComment)
+
+            if (abuse.video) {
+              abuse.embedHtml = this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse))
+
+              if (abuse.video.channel?.ownerAccount) {
+                abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
+              }
+            }
+
+            if (abuse.comment) {
+              if (abuse.comment.deleted) {
+                abuse.truncatedCommentHtml = abuse.commentHtml = this.i18n('Deleted comment')
+              } else {
+                const truncated = truncate(abuse.comment.text, { length: 100 })
+                abuse.truncatedCommentHtml = await this.markdownRenderer.textMarkdownToHTML(truncated, true)
+                abuse.commentHtml = await this.markdownRenderer.textMarkdownToHTML(abuse.comment.text, true)
+              }
+            }
+
+            if (abuse.reporterAccount) {
+              abuse.reporterAccount = new Account(abuse.reporterAccount)
+            }
+
+            if (abuse.flaggedAccount) {
+              abuse.flaggedAccount = new Account(abuse.flaggedAccount)
+            }
+
+            if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
+
+            this.abuses.push(abuse)
+          }
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  private buildInternalActions (): DropdownAction<ProcessedAbuse>[] {
+    return [
+      {
+        label: this.i18n('Internal actions'),
+        isHeader: true
+      },
+      {
+        label: this.i18n('Delete report'),
+        handler: abuse => this.removeAbuse(abuse)
+      },
+      {
+        label: this.i18n('Add note'),
+        handler: abuse => this.openModerationCommentModal(abuse),
+        isDisplayed: abuse => !abuse.moderationComment
+      },
+      {
+        label: this.i18n('Update note'),
+        handler: abuse => this.openModerationCommentModal(abuse),
+        isDisplayed: abuse => !!abuse.moderationComment
+      },
+      {
+        label: this.i18n('Mark as accepted'),
+        handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
+        isDisplayed: abuse => !this.isAbuseAccepted(abuse)
+      },
+      {
+        label: this.i18n('Mark as rejected'),
+        handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
+        isDisplayed: abuse => !this.isAbuseRejected(abuse)
+      }
+    ]
+  }
+
+  private buildFlaggedAccountActions (): DropdownAction<ProcessedAbuse>[] {
+    return [
+      {
+        label: this.i18n('Actions for the flagged account'),
+        isHeader: true,
+        isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video
+      },
+
+      {
+        label: this.i18n('Mute account'),
+        isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
+        handler: abuse => this.muteAccountHelper(abuse.flaggedAccount)
+      },
+
+      {
+        label: this.i18n('Mute server account'),
+        isDisplayed: abuse => abuse.flaggedAccount && !abuse.comment && !abuse.video,
+        handler: abuse => this.muteServerHelper(abuse.flaggedAccount.host)
+      }
+    ]
+  }
+
+  private buildAccountActions (): DropdownAction<ProcessedAbuse>[] {
+    return [
+      {
+        label: this.i18n('Actions for the reporter'),
+        isHeader: true,
+        isDisplayed: abuse => !!abuse.reporterAccount
+      },
+
+      {
+        label: this.i18n('Mute reporter'),
+        isDisplayed: abuse => !!abuse.reporterAccount,
+        handler: abuse => this.muteAccountHelper(abuse.reporterAccount)
+      },
+
+      {
+        label: this.i18n('Mute server'),
+        isDisplayed: abuse => abuse.reporterAccount && !abuse.reporterAccount.userId,
+        handler: abuse => this.muteServerHelper(abuse.reporterAccount.host)
+      }
+    ]
+  }
+
+  private buildVideoActions (): DropdownAction<ProcessedAbuse>[] {
+    return [
+      {
+        label: this.i18n('Actions for the video'),
+        isHeader: true,
+        isDisplayed: abuse => abuse.video && !abuse.video.deleted
+      },
+      {
+        label: this.i18n('Block video'),
+        isDisplayed: abuse => abuse.video && !abuse.video.deleted && !abuse.video.blacklisted,
+        handler: abuse => {
+          this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
+            .subscribe(
+              () => {
+                this.notifier.success(this.i18n('Video blocked.'))
+
+                this.updateAbuseState(abuse, AbuseState.ACCEPTED)
+              },
+
+              err => this.notifier.error(err.message)
+            )
+        }
+      },
+      {
+        label: this.i18n('Unblock video'),
+        isDisplayed: abuse => abuse.video && !abuse.video.deleted && abuse.video.blacklisted,
+        handler: abuse => {
+          this.videoBlocklistService.unblockVideo(abuse.video.id)
+            .subscribe(
+              () => {
+                this.notifier.success(this.i18n('Video unblocked.'))
+
+                this.updateAbuseState(abuse, AbuseState.ACCEPTED)
+              },
+
+              err => this.notifier.error(err.message)
+            )
+        }
+      },
+      {
+        label: this.i18n('Delete video'),
+        isDisplayed: abuse => abuse.video && !abuse.video.deleted,
+        handler: async abuse => {
+          const res = await this.confirmService.confirm(
+            this.i18n('Do you really want to delete this video?'),
+            this.i18n('Delete')
+          )
+          if (res === false) return
+
+          this.videoService.removeVideo(abuse.video.id)
+            .subscribe(
+              () => {
+                this.notifier.success(this.i18n('Video deleted.'))
+
+                this.updateAbuseState(abuse, AbuseState.ACCEPTED)
+              },
+
+              err => this.notifier.error(err.message)
+            )
+        }
+      }
+    ]
+  }
+
+  private buildCommentActions (): DropdownAction<ProcessedAbuse>[] {
+    return [
+      {
+        label: this.i18n('Actions for the comment'),
+        isHeader: true,
+        isDisplayed: abuse => abuse.comment && !abuse.comment.deleted
+      },
+
+      {
+        label: this.i18n('Delete comment'),
+        isDisplayed: abuse => abuse.comment && !abuse.comment.deleted,
+        handler: async abuse => {
+          const res = await this.confirmService.confirm(
+            this.i18n('Do you really want to delete this comment?'),
+            this.i18n('Delete')
+          )
+          if (res === false) return
+
+          this.commentService.deleteVideoComment(abuse.comment.video.id, abuse.comment.id)
+            .subscribe(
+              () => {
+                this.notifier.success(this.i18n('Comment deleted.'))
+
+                this.updateAbuseState(abuse, AbuseState.ACCEPTED)
+              },
+
+              err => this.notifier.error(err.message)
+            )
+        }
+      }
+    ]
+  }
+
+  private muteAccountHelper (account: Account) {
+    this.blocklistService.blockAccountByInstance(account)
+      .subscribe(
+        () => {
+          this.notifier.success(
+            this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
+          )
+
+          account.mutedByInstance = true
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  private muteServerHelper (host: string) {
+    this.blocklistService.blockServerByInstance(host)
+      .subscribe(
+        () => {
+          this.notifier.success(
+            this.i18n('Server {{host}} muted by the instance.', { host: host })
+          )
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  private toHtml (text: string) {
+    return this.markdownRenderer.textMarkdownToHTML(text)
+  }
+}
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 (file)
index 0000000..c6037da
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './abuse-details.component'
+export * from './abuse-list.component'
+export * from './moderation-comment-modal.component'
similarity index 73%
rename from client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts
rename to client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts
index 3cd763ca46ee758ddf6b0ae641b7b1ed0593a16e..23738f9cd0c7a9df0040859d9a9d4a27d33bbe2a 100644 (file)
@@ -1,11 +1,11 @@
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
-import { VideoAbuseService } from '@app/shared/shared-moderation'
+import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
+import { AbuseService } from '@app/shared/shared-moderation'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoAbuse } from '@shared/models'
+import { Abuse } from '@shared/models'
 
 @Component({
   selector: 'my-moderation-comment-modal',
@@ -16,15 +16,15 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
   @ViewChild('modal', { static: true }) modal: NgbModal
   @Output() commentUpdated = new EventEmitter<string>()
 
-  private abuseToComment: VideoAbuse
+  private abuseToComment: Abuse
   private openedModal: NgbModalRef
 
   constructor (
     protected formValidatorService: FormValidatorService,
     private modalService: NgbModal,
     private notifier: Notifier,
-    private videoAbuseService: VideoAbuseService,
-    private videoAbuseValidatorsService: VideoAbuseValidatorsService,
+    private abuseService: AbuseService,
+    private abuseValidatorsService: AbuseValidatorsService,
     private i18n: I18n
   ) {
     super()
@@ -32,11 +32,11 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
 
   ngOnInit () {
     this.buildForm({
-      moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_MODERATION_COMMENT
+      moderationComment: this.abuseValidatorsService.ABUSE_MODERATION_COMMENT
     })
   }
 
-  openModal (abuseToComment: VideoAbuse) {
+  openModal (abuseToComment: Abuse) {
     this.abuseToComment = abuseToComment
     this.openedModal = this.modalService.open(this.modal, { centered: true })
 
@@ -54,7 +54,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
   async banUser () {
     const moderationComment: string = this.form.value[ 'moderationComment' ]
 
-    this.videoAbuseService.updateVideoAbuse(this.abuseToComment, { moderationComment })
+    this.abuseService.updateAbuse(this.abuseToComment, { moderationComment })
         .subscribe(
           () => {
             this.notifier.success(this.i18n('Comment updated.'))
index 16249236c781e3f608c3b36e5ef94c4805a4094c..53e4bc9913245fbb69357d72f0c42417558b7027 100644 (file)
@@ -1,5 +1,5 @@
+export * from './abuse-list'
 export * from './instance-blocklist'
-export * from './video-abuse-list'
 export * from './video-block-list'
 export * from './moderation.component'
 export * from './moderation.routes'
index 0ec420af9bba88cd103b3ae9e03d9ed929805ffa..65fe94d39db30d391a5f2e24a8186677196177f2 100644 (file)
     vertical-align: top;
     text-align: right;
   }
-  
+
   .moderation-expanded-text {
     display: inline-flex;
     word-wrap: break-word;
-  
+
     ::ng-deep p:last-child {
       margin-bottom: 0px !important;
     }
   }
 }
 
-.video-table-states {
+.table-states {
   & > :not(:first-child) {
     margin-left: .4rem;
   }
@@ -59,6 +59,7 @@ p-calendar {
 .screenratio {
   div {
     @include miniature-thumbnail;
+
     display: inline-flex;
     justify-content: center;
     align-items: center;
@@ -72,6 +73,11 @@ p-calendar {
   };
 }
 
+.comment-html {
+  background-color: #ececec;
+  padding: 10px;
+}
+
 .chip {
   @include chip;
 }
@@ -83,16 +89,39 @@ my-action-dropdown.show {
 }
 
 
-.video-table-video-link {
+.table-video-link {
   @include disable-outline;
+
   position: relative;
   top: 3px;
 }
 
-.video-table-video {
+.table-comment-link,
+.table-account-link {
+  @include disable-outline;
+
+  color: var(--mainForegroundColor);
+
+  ::ng-deep p:last-child {
+    margin: 0;
+  }
+}
+
+.table-account-link {
+  display: flex;
+  flex-direction: column;
+}
+
+.comment-flagged-account,
+.account-flagged-handle {
+  font-size: 11px;
+  color: var(--greyForegroundColor);
+}
+
+.table-video {
   display: inline-flex;
 
-  .video-table-video-image {
+  .table-video-image {
     @include miniature-thumbnail;
 
     $image-height: 45px;
@@ -118,7 +147,7 @@ my-action-dropdown.show {
       color: pvar(--inputPlaceholderColor);
     }
 
-    .video-table-video-image-label {
+    .table-video-image-label {
       @include static-thumbnail-overlay;
       position: absolute;
       border-radius: 3px;
@@ -130,7 +159,7 @@ my-action-dropdown.show {
     }
   }
 
-  .video-table-video-text {
+  .table-video-text {
     display: inline-flex;
     flex-direction: column;
     justify-content: center;
@@ -145,7 +174,8 @@ my-action-dropdown.show {
     }
 
     div + div {
-      font-size: 80%;
+      color: var(--greyForegroundColor);
+      font-size: 11px;
     }
   }
 }
index cd837bcb948c04cb4c0eaa3499db8bccaa134b6e..8a31a54dc7cac216dc28d3df485bcab60f5e3508 100644 (file)
@@ -1,7 +1,7 @@
 import { Routes } from '@angular/router'
 import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
 import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
-import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list'
+import { AbuseListComponent } from '@app/+admin/moderation/abuse-list'
 import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
 import { UserRightGuard } from '@app/core'
 import { UserRight } from '@shared/models'
@@ -13,22 +13,27 @@ export const ModerationRoutes: Routes = [
     children: [
       {
         path: '',
-        redirectTo: 'video-abuses/list',
+        redirectTo: 'abuses/list',
         pathMatch: 'full'
       },
       {
         path: 'video-abuses',
-        redirectTo: 'video-abuses/list',
+        redirectTo: 'abuses/list',
         pathMatch: 'full'
       },
       {
         path: 'video-abuses/list',
-        component: VideoAbuseListComponent,
+        redirectTo: 'abuses/list',
+        pathMatch: 'full'
+      },
+      {
+        path: 'abuses/list',
+        component: AbuseListComponent,
         canActivate: [ UserRightGuard ],
         data: {
-          userRight: UserRight.MANAGE_VIDEO_ABUSES,
+          userRight: UserRight.MANAGE_ABUSES,
           meta: {
-            title: 'Video reports'
+            title: 'Reports'
           }
         }
       },
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 (file)
index da7176e..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './video-abuse-list.component'
-export * 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 (file)
index ec808cd..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-<div class="d-flex moderation-expanded">
-  <!-- report left part (report details) -->
-  <div class="col-8">
-
-    <!-- report metadata -->
-    <div class="d-flex">
-      <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
-      <span class="col-9 moderation-expanded-text">
-        <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
-          <img
-            class="avatar"
-            [src]="videoAbuse.reporterAccount.avatar?.path"
-            (error)="switchToDefaultAvatar($event)"
-            alt="Avatar"
-          >
-          <div>
-            <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
-          </div>
-        </a>
-        <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
-          {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
-        </a>
-      </span>
-    </div>
-
-    <div class="d-flex">
-      <span class="col-3 moderation-expanded-label" i18n>Reportee</span>
-      <span class="col-9 moderation-expanded-text">
-        <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
-          <img
-            class="avatar"
-            [src]="videoAbuse.video.channel.ownerAccount?.avatar?.path"
-            (error)="switchToDefaultAvatar($event)"
-            alt="Avatar"
-          >
-          <div>
-            <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
-          </div>
-        </a>
-        <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>
-          {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
-        </a>
-      </span>
-    </div>
-
-    <div class="d-flex" *ngIf="videoAbuse.updatedAt">
-      <span class="col-3 moderation-expanded-label" i18n>Updated</span>
-      <time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time>
-    </div>
-
-    <!-- report text -->
-    <div class="mt-3 d-flex">
-      <span class="col-3 moderation-expanded-label">
-        <ng-container i18n>Report</ng-container>
-        <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id  }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a>
-      </span>
-      <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
-    </div>
-
-    <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
-      <span class="col-3"></span>
-      <span class="col-9">
-        <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id  }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
-          <div>{{ reason.label }}</div>
-        </a>
-      </span>
-    </div>
-
-    <div *ngIf="videoAbuse.startAt" class="mt-2 d-flex">
-      <span class="col-3 moderation-expanded-label" i18n>Reported part</span>
-      <span class="col-9">
-        {{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container>
-      </span>
-    </div>
-
-    <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
-      <span class="col-3 moderation-expanded-label" i18n>Note</span>
-      <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
-    </div>
-
-  </div>
-
-  <!-- report right part (video details) -->
-  <div class="col-4">
-    <div class="screenratio">
-      <div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted">
-        <span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span>
-        <span i18n *ngIf="!videoAbuse.video.deleted">The video was blocked</span>
-      </div>
-      <div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div>
-    </div>
-  </div>
-</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 (file)
index 64641b2..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-<p-table
-  [value]="videoAbuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
-  [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
-  [showCurrentPageReport]="true" i18n-currentPageReportTemplate
-  currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
-  (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
->
-  <ng-template pTemplate="caption">
-    <div class="caption">
-      <div class="ml-auto">
-        <div class="input-group has-feedback has-clear">
-          <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
-            <div class="input-group-text" ngbDropdownToggle>
-              <span class="caret" aria-haspopup="menu" role="button"></span>
-            </div>
-
-            <div role="menu" ngbDropdownMenu>
-              <h6 class="dropdown-header" i18n>Advanced report filters</h6>
-              <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
-              <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
-              <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
-              <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blocked videos</a>
-              <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
-            </div>
-          </div>
-          <input
-            type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
-            (keyup)="onAbuseSearch($event)"
-          >
-          <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a>
-          <span class="sr-only" i18n>Clear filters</span>
-        </div>
-      </div>
-    </div>
-  </ng-template>
-
-  <ng-template pTemplate="header">
-    <tr> <!-- header -->
-      <th style="width: 40px;"></th>
-      <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
-      <th i18n>Video</th>
-      <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
-      <th style="width: 150px;"></th>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse>
-    <tr>
-      <td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
-        <span class="expander">
-          <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
-        </span>
-      </td>
-
-      <td>
-        <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
-          <div class="chip two-lines">
-            <img
-              class="avatar"
-              [src]="videoAbuse.reporterAccount.avatar?.path"
-              (error)="switchToDefaultAvatar($event)"
-              alt="Avatar"
-            >
-            <div>
-              {{ videoAbuse.reporterAccount.displayName }}
-              <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
-            </div>
-          </div>
-        </a>
-      </td>
-
-      <td *ngIf="!videoAbuse.video.deleted">
-        <a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" [title]="videoAbuse.video.name" target="_blank" rel="noopener noreferrer">
-          <div class="video-table-video">
-            <div class="video-table-video-image">
-              <img [src]="videoAbuse.video.thumbnailPath">
-              <span
-                class="video-table-video-image-label" *ngIf="videoAbuse.count > 1"
-                i18n-title title="This video has been reported multiple times."
-              >
-                {{ videoAbuse.nth }}/{{ videoAbuse.count }}
-              </span>
-            </div>
-            <div class="video-table-video-text">
-              <div>
-                <span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span>
-                <span *ngIf="videoAbuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
-                {{ videoAbuse.video.name }}
-              </div>
-              <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
-            </div>
-          </div>
-        </a>
-      </td>
-
-      <td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse">
-        <div class="video-table-video" i18n-title title="Video was deleted">
-          <div class="video-table-video-image">
-            <span i18n>Deleted</span>
-          </div>
-          <div class="video-table-video-text">
-            <div>
-              {{ videoAbuse.video.name }}
-              <span class="glyphicon glyphicon-trash"></span>
-            </div>
-            <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div>
-          </div>
-        </div>
-      </td>
-
-      <td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short'  }}</td>
-
-      <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse">
-        <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span>
-        <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span>
-        <span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span>
-      </td>
-
-      <td class="action-cell">
-        <my-action-dropdown
-          [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
-          i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
-        ></my-action-dropdown>
-      </td>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="rowexpansion" let-videoAbuse>
-      <tr>
-        <td class="expand-cell" colspan="6">
-          <my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details>
-        </td>
-      </tr>
-  </ng-template>
-
-  <ng-template pTemplate="emptymessage">
-    <tr>
-      <td colspan="6">
-        <div class="no-results">
-          <ng-container *ngIf="search" i18n>No video abuses found matching current filters.</ng-container>
-          <ng-container *ngIf="!search" i18n>No video abuses found.</ng-container>
-        </div>
-      </td>
-    </tr>
-  </ng-template>
-</p-table>
-
-<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 (file)
index 409dd42..0000000
+++ /dev/null
@@ -1,328 +0,0 @@
-import { SortMeta } from 'primeng/api'
-import { filter } from 'rxjs/operators'
-import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
-import { environment } from 'src/environments/environment'
-import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
-import { DomSanitizer } from '@angular/platform-browser'
-import { ActivatedRoute, Params, Router } from '@angular/router'
-import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
-import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
-import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoAbuse, VideoAbuseState } from '@shared/models'
-import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
-
-export type ProcessedVideoAbuse = VideoAbuse & {
-  moderationCommentHtml?: string,
-  reasonHtml?: string
-  embedHtml?: string
-  updatedAt?: Date
-  // override bare server-side definitions with rich client-side definitions
-  reporterAccount: Account
-  video: VideoAbuse['video'] & {
-    channel: VideoAbuse['video']['channel'] & {
-      ownerAccount: Account
-    }
-  }
-}
-
-@Component({
-  selector: 'my-video-abuse-list',
-  templateUrl: './video-abuse-list.component.html',
-  styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ]
-})
-export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit {
-  @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
-
-  videoAbuses: ProcessedVideoAbuse[] = []
-  totalRecords = 0
-  sort: SortMeta = { field: 'createdAt', order: 1 }
-  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
-
-  videoAbuseActions: DropdownAction<VideoAbuse>[][] = []
-
-  constructor (
-    private notifier: Notifier,
-    private videoAbuseService: VideoAbuseService,
-    private blocklistService: BlocklistService,
-    private videoService: VideoService,
-    private videoBlocklistService: VideoBlockService,
-    private confirmService: ConfirmService,
-    private i18n: I18n,
-    private markdownRenderer: MarkdownService,
-    private sanitizer: DomSanitizer,
-    private route: ActivatedRoute,
-    private router: Router
-  ) {
-    super()
-
-    this.videoAbuseActions = [
-      [
-        {
-          label: this.i18n('Internal actions'),
-          isHeader: true
-        },
-        {
-          label: this.i18n('Delete report'),
-          handler: videoAbuse => this.removeVideoAbuse(videoAbuse)
-        },
-        {
-          label: this.i18n('Add note'),
-          handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
-          isDisplayed: videoAbuse => !videoAbuse.moderationComment
-        },
-        {
-          label: this.i18n('Update note'),
-          handler: videoAbuse => this.openModerationCommentModal(videoAbuse),
-          isDisplayed: videoAbuse => !!videoAbuse.moderationComment
-        },
-        {
-          label: this.i18n('Mark as accepted'),
-          handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED),
-          isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse)
-        },
-        {
-          label: this.i18n('Mark as rejected'),
-          handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED),
-          isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse)
-        }
-      ],
-      [
-        {
-          label: this.i18n('Actions for the video'),
-          isHeader: true,
-          isDisplayed: videoAbuse => !videoAbuse.video.deleted
-        },
-        {
-          label: this.i18n('Block video'),
-          isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted,
-          handler: videoAbuse => {
-            this.videoBlocklistService.blockVideo(videoAbuse.video.id, undefined, true)
-              .subscribe(
-                () => {
-                  this.notifier.success(this.i18n('Video blocked.'))
-
-                  this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
-                },
-
-                err => this.notifier.error(err.message)
-              )
-          }
-        },
-        {
-          label: this.i18n('Unblock video'),
-          isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted,
-          handler: videoAbuse => {
-            this.videoBlocklistService.unblockVideo(videoAbuse.video.id)
-              .subscribe(
-                () => {
-                  this.notifier.success(this.i18n('Video unblocked.'))
-
-                  this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
-                },
-
-                err => this.notifier.error(err.message)
-              )
-          }
-        },
-        {
-          label: this.i18n('Delete video'),
-          isDisplayed: videoAbuse => !videoAbuse.video.deleted,
-          handler: async videoAbuse => {
-            const res = await this.confirmService.confirm(
-              this.i18n('Do you really want to delete this video?'),
-              this.i18n('Delete')
-            )
-            if (res === false) return
-
-            this.videoService.removeVideo(videoAbuse.video.id)
-              .subscribe(
-                () => {
-                  this.notifier.success(this.i18n('Video deleted.'))
-
-                  this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED)
-                },
-
-                err => this.notifier.error(err.message)
-              )
-          }
-        }
-      ],
-      [
-        {
-          label: this.i18n('Actions for the reporter'),
-          isHeader: true
-        },
-        {
-          label: this.i18n('Mute reporter'),
-          handler: async videoAbuse => {
-            const account = videoAbuse.reporterAccount as Account
-
-            this.blocklistService.blockAccountByInstance(account)
-              .subscribe(
-                () => {
-                  this.notifier.success(
-                    this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost })
-                  )
-
-                  account.mutedByInstance = true
-                },
-
-                err => this.notifier.error(err.message)
-              )
-          }
-        },
-        {
-          label: this.i18n('Mute server'),
-          isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId,
-          handler: async videoAbuse => {
-            this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host)
-              .subscribe(
-                () => {
-                  this.notifier.success(
-                    this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host })
-                  )
-                },
-
-                err => this.notifier.error(err.message)
-              )
-          }
-        }
-      ]
-    ]
-  }
-
-  ngOnInit () {
-    this.initialize()
-
-    this.route.queryParams
-      .subscribe(params => {
-        this.search = params.search || ''
-
-        this.setTableFilter(this.search)
-        this.loadData()
-      })
-  }
-
-  ngAfterViewInit () {
-    if (this.search) this.setTableFilter(this.search)
-  }
-
-  getIdentifier () {
-    return 'VideoAbuseListComponent'
-  }
-
-  openModerationCommentModal (videoAbuse: VideoAbuse) {
-    this.moderationCommentModal.openModal(videoAbuse)
-  }
-
-  onModerationCommentUpdated () {
-    this.loadData()
-  }
-
-  /* Table filter functions */
-  onAbuseSearch (event: Event) {
-    this.onSearch(event)
-    this.setQueryParams((event.target as HTMLInputElement).value)
-  }
-
-  setQueryParams (search: string) {
-    const queryParams: Params = {}
-    if (search) Object.assign(queryParams, { search })
-
-    this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams })
-  }
-
-  resetTableFilter () {
-    this.setTableFilter('')
-    this.setQueryParams('')
-    this.resetSearch()
-  }
-  /* END Table filter functions */
-
-  isVideoAbuseAccepted (videoAbuse: VideoAbuse) {
-    return videoAbuse.state.id === VideoAbuseState.ACCEPTED
-  }
-
-  isVideoAbuseRejected (videoAbuse: VideoAbuse) {
-    return videoAbuse.state.id === VideoAbuseState.REJECTED
-  }
-
-  getVideoUrl (videoAbuse: VideoAbuse) {
-    return Video.buildClientUrl(videoAbuse.video.uuid)
-  }
-
-  getVideoEmbed (videoAbuse: VideoAbuse) {
-    return buildVideoEmbed(
-      buildVideoLink({
-        baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`,
-        title: false,
-        warningTitle: false,
-        startTime: videoAbuse.startAt,
-        stopTime: videoAbuse.endAt
-      })
-    )
-  }
-
-  switchToDefaultAvatar ($event: Event) {
-    ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
-  }
-
-  async removeVideoAbuse (videoAbuse: VideoAbuse) {
-    const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
-    if (res === false) return
-
-    this.videoAbuseService.removeVideoAbuse(videoAbuse).subscribe(
-      () => {
-        this.notifier.success(this.i18n('Abuse deleted.'))
-        this.loadData()
-      },
-
-      err => this.notifier.error(err.message)
-    )
-  }
-
-  updateVideoAbuseState (videoAbuse: VideoAbuse, state: VideoAbuseState) {
-    this.videoAbuseService.updateVideoAbuse(videoAbuse, { state })
-      .subscribe(
-        () => this.loadData(),
-
-        err => this.notifier.error(err.message)
-      )
-  }
-
-  protected loadData () {
-    return this.videoAbuseService.getVideoAbuses({
-      pagination: this.pagination,
-      sort: this.sort,
-      search: this.search
-    }).subscribe(
-        async resultList => {
-          this.totalRecords = resultList.total
-          const videoAbuses = []
-
-          for (const abuse of resultList.data) {
-            Object.assign(abuse, {
-              reasonHtml: await this.toHtml(abuse.reason),
-              moderationCommentHtml: await this.toHtml(abuse.moderationComment),
-              embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)),
-              reporterAccount: new Account(abuse.reporterAccount)
-            })
-
-            if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
-            if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
-
-            videoAbuses.push(abuse as ProcessedVideoAbuse)
-          }
-
-          this.videoAbuses = videoAbuses
-        },
-
-        err => this.notifier.error(err.message)
-      )
-  }
-
-  private toHtml (text: string) {
-    return this.markdownRenderer.textMarkdownToHTML(text)
-  }
-}
index 297e6104c2d439ca25b036b52eefed753b543c7c..2e7b322caf273ffd08c306ba1cfe8ffc8b12bf81 100644 (file)
       </a>
     </div>
     <div>
-      <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }">
-        <div class="dashboard-num">{{ user.videoAbusesCount }}</div>
+      <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + user?.account.displayName + '&quot;' }">
+        <div class="dashboard-num">{{ user.abusesCount }}</div>
         <div class="dashboard-label" i18n>Incriminated in reports</div>
       </a>
     </div>
     <div>
-      <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }">
-        <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div>
+      <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + user?.account.displayName + '&quot; state:accepted' }">
+        <div class="dashboard-num">{{ user.abusesAcceptedCount }} / {{ user.abusesCreatedCount }}</div>
         <div class="dashboard-label" i18n>Authored reports accepted</div>
       </a>
     </div>
index cfa514b260995de938cdd305a4c4f5f44543dd4f..8562e564b85ffa5bac14fcef09392c487c0a2ba2 100644 (file)
@@ -33,7 +33,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
     this.labelNotifications = {
       newVideoFromSubscription: this.i18n('New video from your subscriptions'),
       newCommentOnMyVideo: this.i18n('New comment on your video'),
-      videoAbuseAsModerator: this.i18n('New video abuse'),
+      abuseAsModerator: this.i18n('New abuse'),
       videoAutoBlacklistAsModerator: this.i18n('Video blocked automatically waiting review'),
       blacklistOnMyVideo: this.i18n('One of your video is blocked/unblocked'),
       myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'),
@@ -47,7 +47,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
     this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
 
     this.rightNotifications = {
-      videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES,
+      abuseAsModerator: UserRight.MANAGE_ABUSES,
       videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST,
       newUserRegistration: UserRight.MANAGE_USERS,
       newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW,
index 79505c779acddbbf0fc4b18f9edbc4ea050c668a..d79efbb4963daa1fcd277048613f0ab0ef52cb08 100644 (file)
@@ -4,10 +4,9 @@ import { Router } from '@angular/router'
 import { Notifier, User } from '@app/core'
 import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
 import { Video } from '@app/shared/shared-main'
+import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { VideoCommentCreate } from '@shared/models'
-import { VideoComment } from './video-comment.model'
-import { VideoCommentService } from './video-comment.service'
 
 @Component({
   selector: 'my-video-comment-add',
index 002de57e49ce97965f755c423e4349b6a1a5b6b8..f02ea549a86089778c17784f60ee83f1780f6387 100644 (file)
@@ -45,6 +45,7 @@
           <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
 
           <my-user-moderation-dropdown
+            [prependActions]="prependModerationActions"
             buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
           ></my-user-moderation-dropdown>
         </div>
@@ -93,3 +94,7 @@
     </div>
   </div>
 </div>
+
+<ng-container *ngIf="prependModerationActions">
+  <my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
+</ng-container>
index 27846c1ad213dfc00ddcb4ed7f0d37b2176cb689..6744a0954ed6ef0254651c8ce9f7783b22fef34f 100644 (file)
@@ -1,10 +1,12 @@
-import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
+
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
 import { MarkdownService, Notifier, UserService } from '@app/core'
 import { AuthService } from '@app/core/auth'
-import { Account, Actor, Video } from '@app/shared/shared-main'
+import { Account, Actor, DropdownAction, Video } from '@app/shared/shared-main'
+import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
+import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
+import { I18n } from '@ngx-translate/i18n-polyfill'
 import { User, UserRight } from '@shared/models'
-import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
-import { VideoComment } from './video-comment.model'
 
 @Component({
   selector: 'my-video-comment',
@@ -12,6 +14,8 @@ import { VideoComment } from './video-comment.model'
   styleUrls: ['./video-comment.component.scss']
 })
 export class VideoCommentComponent implements OnInit, OnChanges {
+  @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
+
   @Input() video: Video
   @Input() comment: VideoComment
   @Input() parentComments: VideoComment[] = []
@@ -26,6 +30,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
   @Output() resetReply = new EventEmitter()
   @Output() timestampClicked = new EventEmitter<number>()
 
+  prependModerationActions: DropdownAction<any>[]
+
   sanitizedCommentHTML = ''
   newParentComments: VideoComment[] = []
 
@@ -33,6 +39,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
   commentUser: User
 
   constructor (
+    private i18n: I18n,
     private markdownService: MarkdownService,
     private authService: AuthService,
     private userService: UserService,
@@ -127,5 +134,20 @@ export class VideoCommentComponent implements OnInit, OnChanges {
     } else {
       this.comment.account = null
     }
+
+    if (this.isUserLoggedIn() && this.authService.getUser().account.id !== this.comment.account.id) {
+      this.prependModerationActions = [
+        {
+          label: this.i18n('Report comment'),
+          handler: () => this.showReportModal()
+        }
+      ]
+    } else {
+      this.prependModerationActions = undefined
+    }
+  }
+
+  private showReportModal () {
+    this.commentReportModal.show()
   }
 }
index df0018ec65214936f9225fa1a4c2ccace6386d15..66494a20abbf12e6d8dbb4ea7b687cb09aedf268 100644 (file)
@@ -4,10 +4,8 @@ import { ActivatedRoute } from '@angular/router'
 import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { Syndication, VideoDetails } from '@app/shared/shared-main'
+import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
-import { VideoComment } from './video-comment.model'
-import { VideoCommentService } from './video-comment.service'
 
 @Component({
   selector: 'my-video-comments',
index 421170d8172786a7b81a14bda753b01342f47c6a..5821dc2b7325b2ea04dc36c8b3af3988a7f12e9f 100644 (file)
@@ -5,16 +5,17 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
 import { SharedMainModule } from '@app/shared/shared-main'
 import { SharedModerationModule } from '@app/shared/shared-moderation'
 import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
 import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
 import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
-import { RecommendationsModule } from './recommendations/recommendations.module'
 import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCommentService } from '../../shared/shared-video-comment/video-comment.service'
 import { VideoCommentAddComponent } from './comment/video-comment-add.component'
 import { VideoCommentComponent } from './comment/video-comment.component'
-import { VideoCommentService } from './comment/video-comment.service'
 import { VideoCommentsComponent } from './comment/video-comments.component'
 import { VideoShareComponent } from './modal/video-share.component'
 import { VideoSupportComponent } from './modal/video-support.component'
+import { RecommendationsModule } from './recommendations/recommendations.module'
 import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
 import { VideoDurationPipe } from './video-duration-formatter.pipe'
 import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
@@ -34,7 +35,8 @@ import { VideoWatchComponent } from './video-watch.component'
     SharedVideoPlaylistModule,
     SharedUserSubscriptionModule,
     SharedModerationModule,
-    SharedGlobalIconModule
+    SharedGlobalIconModule,
+    SharedVideoCommentModule
   ],
 
   declarations: [
index 1b35ad47d171687d9691af4797bfa8c68476fbae..e6328eddc1bc99f5441186c5ea91ed6ad7fb8c56 100644 (file)
@@ -3,6 +3,9 @@ import { LazyLoadEvent, SortMeta } from 'primeng/api'
 import { RestPagination } from './rest-pagination'
 import { Subject } from 'rxjs'
 import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+import * as debug from 'debug'
+
+const logger = debug('peertube:tables:RestTable')
 
 export abstract class RestTable {
 
@@ -15,7 +18,7 @@ export abstract class RestTable {
   rowsPerPage = this.rowsPerPageOptions[0]
   expandedRows = {}
 
-  private searchStream: Subject<string>
+  protected searchStream: Subject<string>
 
   abstract getIdentifier (): string
 
@@ -37,6 +40,8 @@ export abstract class RestTable {
   }
 
   loadLazy (event: LazyLoadEvent) {
+    logger('Load lazy %o.', event)
+
     this.sort = {
       order: event.sortOrder,
       field: event.sortField
@@ -65,6 +70,9 @@ export abstract class RestTable {
       )
       .subscribe(search => {
         this.search = search
+
+        logger('On search %s.', this.search)
+
         this.loadData()
       })
   }
@@ -75,14 +83,18 @@ export abstract class RestTable {
   }
 
   onPage (event: { first: number, rows: number }) {
+    logger('On page %o.', event)
+
     if (this.rowsPerPage !== event.rows) {
       this.rowsPerPage = event.rows
       this.pagination = {
         start: event.first,
         count: this.rowsPerPage
       }
+
       this.loadData()
     }
+
     this.expandedRows = {}
   }
 
index 8ecdf9fcd5150fbdcd22d6e258d68632dac2e05d..31b9c21525a462a74276bde53bb2ea256e68cac0 100644 (file)
@@ -51,12 +51,14 @@ export class User implements UserServerModel {
   videoQuotaDaily: number
   videoQuotaUsed?: number
   videoQuotaUsedDaily?: number
+
   videosCount?: number
-  videoAbusesCount?: number
-  videoAbusesAcceptedCount?: number
-  videoAbusesCreatedCount?: number
   videoCommentsCount?: number
 
+  abusesCount?: number
+  abusesAcceptedCount?: number
+  abusesCreatedCount?: number
+
   theme: string
 
   account: Account
@@ -89,9 +91,9 @@ export class User implements UserServerModel {
     this.videoQuotaUsed = hash.videoQuotaUsed
     this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
     this.videosCount = hash.videosCount
-    this.videoAbusesCount = hash.videoAbusesCount
-    this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
-    this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
+    this.abusesCount = hash.abusesCount
+    this.abusesAcceptedCount = hash.abusesAcceptedCount
+    this.abusesCreatedCount = hash.abusesCreatedCount
     this.videoCommentsCount = hash.videoCommentsCount
 
     this.nsfwPolicy = hash.nsfwPolicy
index 2dbe695c9c3f51420b7767b579d32e341a453c5e..0ea251f1c602cb7e1132959210a8d8067ecef6c4 100644 (file)
@@ -28,7 +28,7 @@ export class MenuComponent implements OnInit {
   private routesPerRight: { [ role in UserRight ]?: string } = {
     [UserRight.MANAGE_USERS]: '/admin/users',
     [UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends',
-    [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/moderation/video-abuses',
+    [UserRight.MANAGE_ABUSES]: '/admin/moderation/abuses',
     [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks',
     [UserRight.MANAGE_JOBS]: '/admin/jobs',
     [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
@@ -126,7 +126,7 @@ export class MenuComponent implements OnInit {
     const adminRights = [
       UserRight.MANAGE_USERS,
       UserRight.MANAGE_SERVER_FOLLOW,
-      UserRight.MANAGE_VIDEO_ABUSES,
+      UserRight.MANAGE_ABUSES,
       UserRight.MANAGE_VIDEO_BLACKLIST,
       UserRight.MANAGE_JOBS,
       UserRight.MANAGE_CONFIGURATION
similarity index 81%
rename from client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts
rename to client/src/app/shared/shared-forms/form-validators/abuse-validators.service.ts
index aae56d6072151444e99d4fd27d535983c5d70a90..739115e1952238407671e874718f7e3ca07fcae4 100644 (file)
@@ -4,12 +4,12 @@ import { Injectable } from '@angular/core'
 import { BuildFormValidator } from './form-validator.service'
 
 @Injectable()
-export class VideoAbuseValidatorsService {
-  readonly VIDEO_ABUSE_REASON: BuildFormValidator
-  readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator
+export class AbuseValidatorsService {
+  readonly ABUSE_REASON: BuildFormValidator
+  readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
 
   constructor (private i18n: I18n) {
-    this.VIDEO_ABUSE_REASON = {
+    this.ABUSE_REASON = {
       VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
       MESSAGES: {
         'required': this.i18n('Report reason is required.'),
@@ -18,7 +18,7 @@ export class VideoAbuseValidatorsService {
       }
     }
 
-    this.VIDEO_ABUSE_MODERATION_COMMENT = {
+    this.ABUSE_MODERATION_COMMENT = {
       VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
       MESSAGES: {
         'required': this.i18n('Moderation comment is required.'),
index 8b71841a9db3a68a844566592f44938675a8427c..b06a326ffb45f654edbb14148e88dcc39a25981e 100644 (file)
@@ -1,3 +1,4 @@
+export * from './abuse-validators.service'
 export * from './batch-domains-validators.service'
 export * from './custom-config-validators.service'
 export * from './form-validator.service'
@@ -6,7 +7,6 @@ export * from './instance-validators.service'
 export * from './login-validators.service'
 export * from './reset-password-validators.service'
 export * from './user-validators.service'
-export * from './video-abuse-validators.service'
 export * from './video-accept-ownership-validators.service'
 export * from './video-block-validators.service'
 export * from './video-captions-validators.service'
index e82fa97d436df9ab58f313be8db1504cadc09b61..ba33704cf23c9c27fa8fd4a59061481619448d12 100644 (file)
@@ -11,7 +11,7 @@ import {
   LoginValidatorsService,
   ResetPasswordValidatorsService,
   UserValidatorsService,
-  VideoAbuseValidatorsService,
+  AbuseValidatorsService,
   VideoAcceptOwnershipValidatorsService,
   VideoBlockValidatorsService,
   VideoCaptionsValidatorsService,
@@ -69,7 +69,7 @@ import { TimestampInputComponent } from './timestamp-input.component'
     LoginValidatorsService,
     ResetPasswordValidatorsService,
     UserValidatorsService,
-    VideoAbuseValidatorsService,
+    AbuseValidatorsService,
     VideoAcceptOwnershipValidatorsService,
     VideoBlockValidatorsService,
     VideoCaptionsValidatorsService,
index 5fc7989dd6c833f8eaa0fc3091bf484fc976078e..bda88bdeef44ccd61066d973046b8c1a3763f6f7 100644 (file)
@@ -14,7 +14,9 @@ export abstract class Actor implements ActorServer {
 
   avatarUrl: string
 
-  static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
+  isLocal: boolean
+
+  static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
     if (actor?.avatar?.url) return actor.avatar.url
 
     if (actor && actor.avatar) {
@@ -46,10 +48,16 @@ export abstract class Actor implements ActorServer {
     this.host = hash.host
     this.followingCount = hash.followingCount
     this.followersCount = hash.followersCount
-    this.createdAt = new Date(hash.createdAt.toString())
-    this.updatedAt = new Date(hash.updatedAt.toString())
+
+    if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
+    if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString())
+
     this.avatar = hash.avatar
 
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
+    const thisHost = new URL(absoluteAPIUrl).host
+    this.isLocal = this.host.trim() === thisHost
+
     this.updateComputedAttributes()
   }
 
index de25d3ab90aba90e3963c517e852aa20bb9228e0..61b48a8066ab64aaa971e03655fe1e2b704a98f0 100644 (file)
@@ -25,9 +25,22 @@ export class UserNotification implements UserNotificationServer {
     video: VideoInfo
   }
 
-  videoAbuse?: {
+  abuse?: {
     id: number
-    video: VideoInfo
+
+    video?: VideoInfo
+
+    comment?: {
+      threadId: number
+
+      video: {
+        id: number
+        uuid: string
+        name: string
+      }
+    }
+
+    account?: ActorInfo
   }
 
   videoBlacklist?: {
@@ -55,7 +68,7 @@ export class UserNotification implements UserNotificationServer {
   // Additional fields
   videoUrl?: string
   commentUrl?: any[]
-  videoAbuseUrl?: string
+  abuseUrl?: string
   videoAutoBlacklistUrl?: string
   accountUrl?: string
   videoImportIdentifier?: string
@@ -78,7 +91,7 @@ export class UserNotification implements UserNotificationServer {
       this.comment = hash.comment
       if (this.comment) this.setAvatarUrl(this.comment.account)
 
-      this.videoAbuse = hash.videoAbuse
+      this.abuse = hash.abuse
 
       this.videoBlacklist = hash.videoBlacklist
 
@@ -104,12 +117,15 @@ export class UserNotification implements UserNotificationServer {
         case UserNotificationType.COMMENT_MENTION:
           if (!this.comment) break
           this.accountUrl = this.buildAccountUrl(this.comment.account)
-          this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
+          this.commentUrl = this.buildCommentUrl(this.comment)
           break
 
-        case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
-          this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
-          this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
+        case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
+          this.abuseUrl = '/admin/moderation/abuses/list'
+
+          if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video)
+          else if (this.abuse.comment) this.commentUrl = this.buildCommentUrl(this.abuse.comment)
+          else if (this.abuse.account) this.accountUrl = this.buildAccountUrl(this.abuse.account)
           break
 
         case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
@@ -178,7 +194,11 @@ export class UserNotification implements UserNotificationServer {
     return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
   }
 
-  private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
+  private buildCommentUrl (comment: { video: { uuid: string }, threadId: number }) {
+    return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
+  }
+
+  private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
     actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
   }
 }
index d5be1470ebe3cea2be27ef95cb5393983da9707e..8127ae979c9fa18751585711f31a326a82a2b388 100644 (file)
@@ -19,7 +19,7 @@
 
         <ng-template #noVideo>
           <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-  
+
           <div class="message" i18n>
             The notification concerns a video now unavailable
           </div>
         </div>
       </ng-container>
 
-      <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
+      <ng-container *ngSwitchCase="UserNotificationType.NEW_ABUSE_FOR_MODERATORS">
         <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
 
-        <div class="message" i18n>
-          <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>
+        <div class="message" *ngIf="notification.videoUrl" i18n>
+          <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>
+        </div>
+
+        <div class="message" *ngIf="notification.commentUrl" i18n>
+          <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>
+        </div>
+
+        <div class="message" *ngIf="notification.accountUrl" i18n>
+          <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>
+        </div>
+
+        <!-- Deleted entity associated to the abuse -->
+        <div class="message" *ngIf="!notification.videoUrl && !notification.commentUrl && !notification.accountUrl" i18n>
+          <a (click)="markAsRead(notification)" [routerLink]="notification.abuseUrl">A new abuse</a> has been created
         </div>
       </ng-container>
 
@@ -65,7 +78,7 @@
           <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
             <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
           </a>
-  
+
           <div class="message" i18n>
             <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>
           </div>
@@ -73,7 +86,7 @@
 
         <ng-template #noComment>
           <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-  
+
           <div class="message" i18n>
             The notification concerns a comment now unavailable
           </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 (file)
index 0000000..95ac169
--- /dev/null
@@ -0,0 +1,154 @@
+import { omit } from 'lodash-es'
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { Abuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList } from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Injectable()
+export class AbuseService {
+  private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
+
+  constructor (
+    private i18n: I18n,
+    private authHttp: HttpClient,
+    private restService: RestService,
+    private restExtractor: RestExtractor
+  ) { }
+
+  getAbuses (options: {
+    pagination: RestPagination,
+    sort: SortMeta,
+    search?: string
+  }): Observable<ResultList<Abuse>> {
+    const { pagination, sort, search } = options
+    const url = AbuseService.BASE_ABUSE_URL
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (search) {
+      const filters = this.restService.parseQueryStringFilter(search, {
+        id: { prefix: '#' },
+        state: {
+          prefix: 'state:',
+          handler: v => {
+            if (v === 'accepted') return AbuseState.ACCEPTED
+            if (v === 'pending') return AbuseState.PENDING
+            if (v === 'rejected') return AbuseState.REJECTED
+
+            return undefined
+          }
+        },
+        videoIs: {
+          prefix: 'videoIs:',
+          handler: v => {
+            if (v === 'deleted') return v
+            if (v === 'blacklisted') return v
+
+            return undefined
+          }
+        },
+        searchReporter: { prefix: 'reporter:' },
+        searchReportee: { prefix: 'reportee:' },
+        predefinedReason: { prefix: 'tag:' }
+      })
+
+      params = this.restService.addObjectParams(params, filters)
+    }
+
+    return this.authHttp.get<ResultList<Abuse>>(url, { params })
+      .pipe(
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  reportVideo (parameters: AbuseCreate) {
+    const url = AbuseService.BASE_ABUSE_URL
+
+    const body = omit(parameters, ['id'])
+
+    return this.authHttp.post(url, body)
+      .pipe(
+        map(this.restExtractor.extractDataBool),
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
+    const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
+
+    return this.authHttp.put(url, abuseUpdate)
+      .pipe(
+        map(this.restExtractor.extractDataBool),
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  removeAbuse (abuse: Abuse) {
+    const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
+
+    return this.authHttp.delete(url)
+      .pipe(
+        map(this.restExtractor.extractDataBool),
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  getPrefefinedReasons (type: AbuseFilter) {
+    let reasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [
+      {
+        id: 'violentOrRepulsive',
+        label: this.i18n('Violent or repulsive'),
+        help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
+      },
+      {
+        id: 'hatefulOrAbusive',
+        label: this.i18n('Hateful or abusive'),
+        help: this.i18n('Contains abusive, racist or sexist language or iconography.')
+      },
+      {
+        id: 'spamOrMisleading',
+        label: this.i18n('Spam, ad or false news'),
+        help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
+      },
+      {
+        id: 'privacy',
+        label: this.i18n('Privacy breach or doxxing'),
+        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).')
+      },
+      {
+        id: 'rights',
+        label: this.i18n('Intellectual property violation'),
+        help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
+      },
+      {
+        id: 'serverRules',
+        label: this.i18n('Breaks server rules'),
+        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.')
+      }
+    ]
+
+    if (type === 'video') {
+      reasons = reasons.concat([
+        {
+          id: 'thumbnails',
+          label: this.i18n('Thumbnails'),
+          help: this.i18n('The above can only be seen in thumbnails.')
+        },
+        {
+          id: 'captions',
+          label: this.i18n('Captions'),
+          help: this.i18n('The above can only be seen in captions (please describe which).')
+        }
+      ])
+    }
+
+    return reasons
+  }
+
+}
index 8e74254f6957fb869f66c6c235abdb44d81d8064..41c910ffe5c4cae67e54c581d93589db3f8b5051 100644 (file)
@@ -1,3 +1,6 @@
+export * from './report-modals'
+
+export * from './abuse.service'
 export * from './account-block.model'
 export * from './account-blocklist.component'
 export * from './batch-domains-modal.component'
@@ -6,8 +9,6 @@ export * from './bulk.service'
 export * from './server-blocklist.component'
 export * from './user-ban-modal.component'
 export * from './user-moderation-dropdown.component'
-export * from './video-abuse.service'
 export * from './video-block.component'
 export * from './video-block.service'
-export * from './video-report.component'
 export * 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 (file)
index 0000000..78ca934
--- /dev/null
@@ -0,0 +1,94 @@
+import { mapValues, pickBy } from 'lodash-es'
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { Notifier } from '@app/core'
+import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { Account } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
+import { AbuseService } from '../abuse.service'
+
+@Component({
+  selector: 'my-account-report',
+  templateUrl: './report.component.html',
+  styleUrls: [ './report.component.scss' ]
+})
+export class AccountReportComponent extends FormReactive implements OnInit {
+  @Input() account: Account = null
+
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  error: string = null
+  predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
+  modalTitle: string
+
+  private openedModal: NgbModalRef
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private modalService: NgbModal,
+    private abuseValidatorsService: AbuseValidatorsService,
+    private abuseService: AbuseService,
+    private notifier: Notifier,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  get currentHost () {
+    return window.location.host
+  }
+
+  get originHost () {
+    if (this.isRemote()) {
+      return this.account.host
+    }
+
+    return ''
+  }
+
+  ngOnInit () {
+    this.modalTitle = this.i18n('Report {{displayName}}', { displayName: this.account.displayName })
+
+    this.buildForm({
+      reason: this.abuseValidatorsService.ABUSE_REASON,
+      predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
+    })
+
+    this.predefinedReasons = this.abuseService.getPrefefinedReasons('account')
+  }
+
+  show () {
+    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
+  }
+
+  hide () {
+    this.openedModal.close()
+    this.openedModal = null
+  }
+
+  report () {
+    const reason = this.form.get('reason').value
+    const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
+
+    this.abuseService.reportVideo({
+      reason,
+      predefinedReasons,
+      account: {
+        id: this.account.id
+      }
+    }).subscribe(
+      () => {
+        this.notifier.success(this.i18n('Account reported.'))
+        this.hide()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+
+  isRemote () {
+    return !this.account.isLocal
+  }
+}
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 (file)
index 0000000..00d7b8d
--- /dev/null
@@ -0,0 +1,94 @@
+import { mapValues, pickBy } from 'lodash-es'
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { Notifier } from '@app/core'
+import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { VideoComment } from '@app/shared/shared-video-comment'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
+import { AbuseService } from '../abuse.service'
+
+@Component({
+  selector: 'my-comment-report',
+  templateUrl: './report.component.html',
+  styleUrls: [ './report.component.scss' ]
+})
+export class CommentReportComponent extends FormReactive implements OnInit {
+  @Input() comment: VideoComment = null
+
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  modalTitle: string
+  error: string = null
+  predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
+
+  private openedModal: NgbModalRef
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private modalService: NgbModal,
+    private abuseValidatorsService: AbuseValidatorsService,
+    private abuseService: AbuseService,
+    private notifier: Notifier,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  get currentHost () {
+    return window.location.host
+  }
+
+  get originHost () {
+    if (this.isRemote()) {
+      return this.comment.account.host
+    }
+
+    return ''
+  }
+
+  ngOnInit () {
+    this.modalTitle = this.i18n('Report comment')
+
+    this.buildForm({
+      reason: this.abuseValidatorsService.ABUSE_REASON,
+      predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null)
+    })
+
+    this.predefinedReasons = this.abuseService.getPrefefinedReasons('comment')
+  }
+
+  show () {
+    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
+  }
+
+  hide () {
+    this.openedModal.close()
+    this.openedModal = null
+  }
+
+  report () {
+    const reason = this.form.get('reason').value
+    const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
+
+    this.abuseService.reportVideo({
+      reason,
+      predefinedReasons,
+      comment: {
+        id: this.comment.id
+      }
+    }).subscribe(
+      () => {
+        this.notifier.success(this.i18n('Comment reported.'))
+        this.hide()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+
+  isRemote () {
+    return !this.comment.isLocal
+  }
+}
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 (file)
index 0000000..f3c4058
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './account-report.component'
+export * from './comment-report.component'
+export * 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 (file)
index 0000000..bda6231
--- /dev/null
@@ -0,0 +1,62 @@
+<ng-template #modal>
+  <div class="modal-header">
+    <h4 class="modal-title">{{ modalTitle }}</h4>
+    <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+  </div>
+
+  <div class="modal-body">
+    <form novalidate [formGroup]="form" (ngSubmit)="report()">
+
+    <div class="row">
+      <div class="col-5 form-group">
+
+          <label i18n for="reportPredefinedReasons">What is the issue?</label>
+
+          <div class="ml-2 mt-2 d-flex flex-column">
+            <ng-container formGroupName="predefinedReasons">
+
+              <div class="form-group" *ngFor="let reason of predefinedReasons">
+                <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
+                  <ng-template *ngIf="reason.help" ptTemplate="help">
+                    <div [innerHTML]="reason.help"></div>
+                  </ng-template>
+
+                  <ng-container *ngIf="reason.description" ngProjectAs="description">
+                    <div [innerHTML]="reason.description"></div>
+                  </ng-container>
+                </my-peertube-checkbox>
+              </div>
+
+            </ng-container>
+          </div>
+
+      </div>
+
+      <div class="col-7">
+        <div i18n class="information">
+          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>.
+        </div>
+
+        <div class="form-group">
+          <textarea
+            i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
+            [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
+          ></textarea>
+          <div *ngIf="formErrors.reason" class="form-error">
+            {{ formErrors.reason }}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-group inputs">
+      <input
+        type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+        (click)="hide()" (key.enter)="hide()"
+      >
+      <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
+    </div>
+
+    </form>
+  </div>
+</ng-template>
similarity index 92%
rename from client/src/app/shared/shared-moderation/video-report.component.html
rename to client/src/app/shared/shared-moderation/report-modals/video-report.component.html
index d6beb6d2ad64a79b81ca6575db00a270268b4142..4947088d14579a365e49398e0a4d1d650bd47139 100644 (file)
 
           <div class="ml-2 mt-2 d-flex flex-column">
             <ng-container formGroupName="predefinedReasons">
+
               <div class="form-group" *ngFor="let reason of predefinedReasons">
-                <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
+                <my-peertube-checkbox [inputName]="reason.id" [formControlName]="reason.id" [labelText]="reason.label">
                   <ng-template *ngIf="reason.help" ptTemplate="help">
                     <div [innerHTML]="reason.help"></div>
                   </ng-template>
+
                   <ng-container *ngIf="reason.description" ngProjectAs="description">
                     <div [innerHTML]="reason.description"></div>
                   </ng-container>
                 </my-peertube-checkbox>
               </div>
+
             </ng-container>
           </div>
 
         </div>
 
         <div i18n class="information">
-          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>.
+          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>.
         </div>
 
         <div class="form-group">
-          <textarea 
+          <textarea
             i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
             [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
           ></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 (file)
index 0000000..7d53ea3
--- /dev/null
@@ -0,0 +1,122 @@
+import { mapValues, pickBy } from 'lodash-es'
+import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
+import { Notifier } from '@app/core'
+import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
+import { Video } from '../../shared-main'
+import { AbuseService } from '../abuse.service'
+
+@Component({
+  selector: 'my-video-report',
+  templateUrl: './video-report.component.html',
+  styleUrls: [ './report.component.scss' ]
+})
+export class VideoReportComponent extends FormReactive implements OnInit {
+  @Input() video: Video = null
+
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  error: string = null
+  predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
+  embedHtml: SafeHtml
+
+  private openedModal: NgbModalRef
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private modalService: NgbModal,
+    private abuseValidatorsService: AbuseValidatorsService,
+    private abuseService: AbuseService,
+    private notifier: Notifier,
+    private sanitizer: DomSanitizer,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  get currentHost () {
+    return window.location.host
+  }
+
+  get originHost () {
+    if (this.isRemote()) {
+      return this.video.account.host
+    }
+
+    return ''
+  }
+
+  get timestamp () {
+    return this.form.get('timestamp').value
+  }
+
+  getVideoEmbed () {
+    return this.sanitizer.bypassSecurityTrustHtml(
+      buildVideoEmbed(
+        buildVideoLink({
+          baseUrl: this.video.embedUrl,
+          title: false,
+          warningTitle: false
+        })
+      )
+    )
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      reason: this.abuseValidatorsService.ABUSE_REASON,
+      predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null),
+      timestamp: {
+        hasStart: null,
+        startAt: null,
+        hasEnd: null,
+        endAt: null
+      }
+    })
+
+    this.predefinedReasons = this.abuseService.getPrefefinedReasons('video')
+
+    this.embedHtml = this.getVideoEmbed()
+  }
+
+  show () {
+    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
+  }
+
+  hide () {
+    this.openedModal.close()
+    this.openedModal = null
+  }
+
+  report () {
+    const reason = this.form.get('reason').value
+    const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
+    const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
+
+    this.abuseService.reportVideo({
+      reason,
+      predefinedReasons,
+      video: {
+        id: this.video.id,
+        startAt: hasStart && startAt ? startAt : undefined,
+        endAt: hasEnd && endAt ? endAt : undefined
+      }
+    }).subscribe(
+      () => {
+        this.notifier.success(this.i18n('Video reported.'))
+        this.hide()
+      },
+
+      err => this.notifier.error(err.message)
+    )
+  }
+
+  isRemote () {
+    return !this.video.isLocal
+  }
+}
index f7e64dfa3422c95a41f33a5697bc3556079156cf..8fa9ee794b847356d93ba4ef9b14f097c1472f0b 100644 (file)
@@ -3,21 +3,23 @@ import { NgModule } from '@angular/core'
 import { SharedFormModule } from '../shared-forms/shared-form.module'
 import { SharedGlobalIconModule } from '../shared-icons'
 import { SharedMainModule } from '../shared-main/shared-main.module'
+import { SharedVideoCommentModule } from '../shared-video-comment'
+import { AbuseService } from './abuse.service'
 import { BatchDomainsModalComponent } from './batch-domains-modal.component'
 import { BlocklistService } from './blocklist.service'
 import { BulkService } from './bulk.service'
 import { UserBanModalComponent } from './user-ban-modal.component'
 import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
-import { VideoAbuseService } from './video-abuse.service'
 import { VideoBlockComponent } from './video-block.component'
 import { VideoBlockService } from './video-block.service'
-import { VideoReportComponent } from './video-report.component'
+import { VideoReportComponent, AccountReportComponent, CommentReportComponent } from './report-modals'
 
 @NgModule({
   imports: [
     SharedMainModule,
     SharedFormModule,
-    SharedGlobalIconModule
+    SharedGlobalIconModule,
+    SharedVideoCommentModule
   ],
 
   declarations: [
@@ -25,7 +27,9 @@ import { VideoReportComponent } from './video-report.component'
     UserModerationDropdownComponent,
     VideoBlockComponent,
     VideoReportComponent,
-    BatchDomainsModalComponent
+    BatchDomainsModalComponent,
+    CommentReportComponent,
+    AccountReportComponent
   ],
 
   exports: [
@@ -33,13 +37,15 @@ import { VideoReportComponent } from './video-report.component'
     UserModerationDropdownComponent,
     VideoBlockComponent,
     VideoReportComponent,
-    BatchDomainsModalComponent
+    BatchDomainsModalComponent,
+    CommentReportComponent,
+    AccountReportComponent
   ],
 
   providers: [
     BlocklistService,
     BulkService,
-    VideoAbuseService,
+    AbuseService,
     VideoBlockService
   ]
 })
index d3c37f082e713854b57d9e9d3334e357c660a9f7..78c2658df60f91f9d9846a8dc77c07d0356c0f50 100644 (file)
@@ -16,6 +16,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
 
   @Input() user: User
   @Input() account: Account
+  @Input() prependActions: DropdownAction<{ user: User, account: Account }>[]
 
   @Input() buttonSize: 'normal' | 'small' = 'normal'
   @Input() placement = 'left-top left-bottom auto'
@@ -250,6 +251,12 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
   private buildActions () {
     this.userActions = []
 
+    if (this.prependActions) {
+      this.userActions = [
+        this.prependActions
+      ]
+    }
+
     if (this.authService.isLoggedIn()) {
       const authUser = this.authService.getUser()
 
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 (file)
index 44dea44..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-import { omit } from 'lodash-es'
-import { SortMeta } from 'primeng/api'
-import { Observable } from 'rxjs'
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { RestExtractor, RestPagination, RestService } from '@app/core'
-import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models'
-import { environment } from '../../../environments/environment'
-
-@Injectable()
-export class VideoAbuseService {
-  private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restService: RestService,
-    private restExtractor: RestExtractor
-  ) {}
-
-  getVideoAbuses (options: {
-    pagination: RestPagination,
-    sort: SortMeta,
-    search?: string
-  }): Observable<ResultList<VideoAbuse>> {
-    const { pagination, sort, search } = options
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    if (search) {
-      const filters = this.restService.parseQueryStringFilter(search, {
-        id: { prefix: '#' },
-        state: {
-          prefix: 'state:',
-          handler: v => {
-            if (v === 'accepted') return VideoAbuseState.ACCEPTED
-            if (v === 'pending') return VideoAbuseState.PENDING
-            if (v === 'rejected') return VideoAbuseState.REJECTED
-
-            return undefined
-          }
-        },
-        videoIs: {
-          prefix: 'videoIs:',
-          handler: v => {
-            if (v === 'deleted') return v
-            if (v === 'blacklisted') return v
-
-            return undefined
-          }
-        },
-        searchReporter: { prefix: 'reporter:' },
-        searchReportee: { prefix: 'reportee:' },
-        predefinedReason: { prefix: 'tag:' }
-      })
-
-      params = this.restService.addObjectParams(params, filters)
-    }
-
-    return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
-               .pipe(
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  reportVideo (parameters: { id: number } & VideoAbuseCreate) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
-
-    const body = omit(parameters, [ 'id' ])
-
-    return this.authHttp.post(url, body)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
-    return this.authHttp.put(url, abuseUpdate)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }
-
-  removeVideoAbuse (videoAbuse: VideoAbuse) {
-    const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
-    return this.authHttp.delete(url)
-               .pipe(
-                 map(this.restExtractor.extractDataBool),
-                 catchError(res => this.restExtractor.handleError(res))
-               )
-  }}
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 (file)
index 11c8056..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-import { mapValues, pickBy } from 'lodash-es'
-import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
-import { Component, Input, OnInit, ViewChild } from '@angular/core'
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
-import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model'
-import { Video } from '../shared-main'
-import { VideoAbuseService } from './video-abuse.service'
-
-@Component({
-  selector: 'my-video-report',
-  templateUrl: './video-report.component.html',
-  styleUrls: [ './video-report.component.scss' ]
-})
-export class VideoReportComponent extends FormReactive implements OnInit {
-  @Input() video: Video = null
-
-  @ViewChild('modal', { static: true }) modal: NgbModal
-
-  error: string = null
-  predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
-  embedHtml: SafeHtml
-
-  private openedModal: NgbModalRef
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private modalService: NgbModal,
-    private videoAbuseValidatorsService: VideoAbuseValidatorsService,
-    private videoAbuseService: VideoAbuseService,
-    private notifier: Notifier,
-    private sanitizer: DomSanitizer,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  get currentHost () {
-    return window.location.host
-  }
-
-  get originHost () {
-    if (this.isRemoteVideo()) {
-      return this.video.account.host
-    }
-
-    return ''
-  }
-
-  get timestamp () {
-    return this.form.get('timestamp').value
-  }
-
-  getVideoEmbed () {
-    return this.sanitizer.bypassSecurityTrustHtml(
-      buildVideoEmbed(
-        buildVideoLink({
-          baseUrl: this.video.embedUrl,
-          title: false,
-          warningTitle: false
-        })
-      )
-    )
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
-      predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
-      timestamp: {
-        hasStart: null,
-        startAt: null,
-        hasEnd: null,
-        endAt: null
-      }
-    })
-
-    this.predefinedReasons = [
-      {
-        id: 'violentOrRepulsive',
-        label: this.i18n('Violent or repulsive'),
-        help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
-      },
-      {
-        id: 'hatefulOrAbusive',
-        label: this.i18n('Hateful or abusive'),
-        help: this.i18n('Contains abusive, racist or sexist language or iconography.')
-      },
-      {
-        id: 'spamOrMisleading',
-        label: this.i18n('Spam, ad or false news'),
-        help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
-      },
-      {
-        id: 'privacy',
-        label: this.i18n('Privacy breach or doxxing'),
-        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).')
-      },
-      {
-        id: 'rights',
-        label: this.i18n('Intellectual property violation'),
-        help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
-      },
-      {
-        id: 'serverRules',
-        label: this.i18n('Breaks server rules'),
-        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.')
-      },
-      {
-        id: 'thumbnails',
-        label: this.i18n('Thumbnails'),
-        help: this.i18n('The above can only be seen in thumbnails.')
-      },
-      {
-        id: 'captions',
-        label: this.i18n('Captions'),
-        help: this.i18n('The above can only be seen in captions (please describe which).')
-      }
-    ]
-
-    this.embedHtml = this.getVideoEmbed()
-  }
-
-  show () {
-    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
-  }
-
-  hide () {
-    this.openedModal.close()
-    this.openedModal = null
-  }
-
-  report () {
-    const reason = this.form.get('reason').value
-    const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
-    const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
-
-    this.videoAbuseService.reportVideo({
-      id: this.video.id,
-      reason,
-      predefinedReasons,
-      startAt: hasStart && startAt ? startAt : undefined,
-      endAt: hasEnd && endAt ? endAt : undefined
-    }).subscribe(
-      () => {
-        this.notifier.success(this.i18n('Video reported.'))
-        this.hide()
-      },
-
-      err => this.notifier.error(err.message)
-    )
-  }
-
-  isRemoteVideo () {
-    return !this.video.isLocal
-  }
-}
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 (file)
index 0000000..b1195f2
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './video-comment.service'
+export * from './video-comment.model'
+export * from './video-comment-thread-tree.model'
+
+export * 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 (file)
index 0000000..41b3298
--- /dev/null
@@ -0,0 +1,19 @@
+
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { VideoCommentService } from './video-comment.service'
+
+@NgModule({
+  imports: [
+    SharedMainModule
+  ],
+
+  declarations: [ ],
+
+  exports: [ ],
+
+  providers: [
+    VideoCommentService
+  ]
+})
+export class SharedVideoCommentModule { }
similarity index 98%
rename from client/src/app/+videos/+video-watch/comment/video-comment.service.ts
rename to client/src/app/shared/shared-video-comment/video-comment.service.ts
index a73fb9ca853525031dfbd42298942b4113d350a8..81c65aa3863e53413d7b812ca1c86ad448684097 100644 (file)
@@ -11,7 +11,7 @@ import {
   VideoCommentCreate,
   VideoCommentThreadTree as VideoCommentThreadTreeServerModel
 } from '@shared/models'
-import { environment } from '../../../../environments/environment'
+import { environment } from '../../../environments/environment'
 import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
 import { VideoComment } from './video-comment.model'
 
diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts
new file mode 100644 (file)
index 0000000..04a0c06
--- /dev/null
@@ -0,0 +1,168 @@
+import * as express from 'express'
+import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
+import { AbuseModel } from '@server/models/abuse/abuse'
+import { getServerActor } from '@server/models/application/application'
+import { AbuseCreate, abusePredefinedReasonsMap, AbuseState, UserRight } from '../../../shared'
+import { getFormattedObjects } from '../../helpers/utils'
+import { sequelizeTypescript } from '../../initializers/database'
+import {
+  abuseGetValidator,
+  abuseListValidator,
+  abuseReportValidator,
+  abusesSortValidator,
+  abuseUpdateValidator,
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  ensureUserHasRight,
+  paginationValidator,
+  setDefaultPagination,
+  setDefaultSort
+} from '../../middlewares'
+import { AccountModel } from '../../models/account/account'
+
+const abuseRouter = express.Router()
+
+abuseRouter.get('/',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
+  paginationValidator,
+  abusesSortValidator,
+  setDefaultSort,
+  setDefaultPagination,
+  abuseListValidator,
+  asyncMiddleware(listAbuses)
+)
+abuseRouter.put('/:id',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
+  asyncMiddleware(abuseUpdateValidator),
+  asyncRetryTransactionMiddleware(updateAbuse)
+)
+abuseRouter.post('/',
+  authenticate,
+  asyncMiddleware(abuseReportValidator),
+  asyncRetryTransactionMiddleware(reportAbuse)
+)
+abuseRouter.delete('/:id',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
+  asyncMiddleware(abuseGetValidator),
+  asyncRetryTransactionMiddleware(deleteAbuse)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  abuseRouter,
+
+  // FIXME: deprecated in 2.3. Remove these exports
+  listAbuses,
+  updateAbuse,
+  deleteAbuse,
+  reportAbuse
+}
+
+// ---------------------------------------------------------------------------
+
+async function listAbuses (req: express.Request, res: express.Response) {
+  const user = res.locals.oauth.token.user
+  const serverActor = await getServerActor()
+
+  const resultList = await AbuseModel.listForApi({
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    id: req.query.id,
+    filter: req.query.filter,
+    predefinedReason: req.query.predefinedReason,
+    search: req.query.search,
+    state: req.query.state,
+    videoIs: req.query.videoIs,
+    searchReporter: req.query.searchReporter,
+    searchReportee: req.query.searchReportee,
+    searchVideo: req.query.searchVideo,
+    searchVideoChannel: req.query.searchVideoChannel,
+    serverAccountId: serverActor.Account.id,
+    user
+  })
+
+  return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function updateAbuse (req: express.Request, res: express.Response) {
+  const abuse = res.locals.abuse
+
+  if (req.body.moderationComment !== undefined) abuse.moderationComment = req.body.moderationComment
+  if (req.body.state !== undefined) abuse.state = req.body.state
+
+  await sequelizeTypescript.transaction(t => {
+    return abuse.save({ transaction: t })
+  })
+
+  // Do not send the delete to other instances, we updated OUR copy of this abuse
+
+  return res.type('json').status(204).end()
+}
+
+async function deleteAbuse (req: express.Request, res: express.Response) {
+  const abuse = res.locals.abuse
+
+  await sequelizeTypescript.transaction(t => {
+    return abuse.destroy({ transaction: t })
+  })
+
+  // Do not send the delete to other instances, we delete OUR copy of this abuse
+
+  return res.type('json').status(204).end()
+}
+
+async function reportAbuse (req: express.Request, res: express.Response) {
+  const videoInstance = res.locals.videoAll
+  const commentInstance = res.locals.videoCommentFull
+  const accountInstance = res.locals.account
+
+  const body: AbuseCreate = req.body
+
+  const { id } = await sequelizeTypescript.transaction(async t => {
+    const reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
+    const predefinedReasons = body.predefinedReasons?.map(r => abusePredefinedReasonsMap[r])
+
+    const baseAbuse = {
+      reporterAccountId: reporterAccount.id,
+      reason: body.reason,
+      state: AbuseState.PENDING,
+      predefinedReasons
+    }
+
+    if (body.video) {
+      return createVideoAbuse({
+        baseAbuse,
+        videoInstance,
+        reporterAccount,
+        transaction: t,
+        startAt: body.video.startAt,
+        endAt: body.video.endAt
+      })
+    }
+
+    if (body.comment) {
+      return createVideoCommentAbuse({
+        baseAbuse,
+        commentInstance,
+        reporterAccount,
+        transaction: t
+      })
+    }
+
+    // Account report
+    return createAccountAbuse({
+      baseAbuse,
+      accountInstance,
+      reporterAccount,
+      transaction: t
+    })
+  })
+
+  return res.json({ abuse: { id } })
+}
index c334a26b48c3807ce7f66c2aa0e7c13ee4b7ba8e..eda9e04d197053bbb894bb0ea2ffe33d6c96a9bf 100644 (file)
@@ -3,6 +3,7 @@ import * as express from 'express'
 import * as RateLimit from 'express-rate-limit'
 import { badRequest } from '../../helpers/express-utils'
 import { CONFIG } from '../../initializers/config'
+import { abuseRouter } from './abuse'
 import { accountsRouter } from './accounts'
 import { bulkRouter } from './bulk'
 import { configRouter } from './config'
@@ -32,6 +33,7 @@ const apiRateLimiter = RateLimit({
 apiRouter.use(apiRateLimiter)
 
 apiRouter.use('/server', serverRouter)
+apiRouter.use('/abuses', abuseRouter)
 apiRouter.use('/bulk', bulkRouter)
 apiRouter.use('/oauth-clients', oauthClientsRouter)
 apiRouter.use('/config', configRouter)
index 77a15e5fc12c039d76e27002aea71270673688aa..dc915977fc556c4394a366d88efd41963b5ff15c 100644 (file)
@@ -50,7 +50,5 @@ async function removeUserHistory (req: express.Request, res: express.Response) {
     return UserVideoHistoryModel.removeUserHistoryBefore(user, beforeDate, t)
   })
 
-  // Do not send the delete to other instances, we delete OUR copy of this video abuse
-
   return res.type('json').status(204).end()
 }
index 017f5219edeb5a3b65bf6bbe855243d7d35aaefb..0be51c128df31430d52c403c5202c27e7e182a33 100644 (file)
@@ -68,7 +68,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
   const values: UserNotificationSetting = {
     newVideoFromSubscription: body.newVideoFromSubscription,
     newCommentOnMyVideo: body.newCommentOnMyVideo,
-    videoAbuseAsModerator: body.videoAbuseAsModerator,
+    abuseAsModerator: body.abuseAsModerator,
     videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator,
     blacklistOnMyVideo: body.blacklistOnMyVideo,
     myVideoPublished: body.myVideoPublished,
index ab207445950daa9169502744b9b75a68e3774e36..b92a66360a684ad4599c742fea705a958690123e 100644 (file)
@@ -1,9 +1,10 @@
 import * as express from 'express'
-import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared'
-import { logger } from '../../../helpers/logger'
+import { AbuseModel } from '@server/models/abuse/abuse'
+import { getServerActor } from '@server/models/application/application'
+import { AbuseCreate, UserRight, VideoAbuseCreate } from '../../../../shared'
 import { getFormattedObjects } from '../../../helpers/utils'
-import { sequelizeTypescript } from '../../../initializers/database'
 import {
+  abusesSortValidator,
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
   authenticate,
@@ -12,28 +13,21 @@ import {
   setDefaultPagination,
   setDefaultSort,
   videoAbuseGetValidator,
+  videoAbuseListValidator,
   videoAbuseReportValidator,
-  videoAbusesSortValidator,
-  videoAbuseUpdateValidator,
-  videoAbuseListValidator
+  videoAbuseUpdateValidator
 } from '../../../middlewares'
-import { AccountModel } from '../../../models/account/account'
-import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
-import { Notifier } from '../../../lib/notifier'
-import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag'
-import { MVideoAbuseAccountVideo } from '../../../types/models/video'
-import { getServerActor } from '@server/models/application/application'
-import { MAccountDefault } from '@server/types/models'
+import { deleteAbuse, reportAbuse, updateAbuse } from '../abuse'
+
+// FIXME: deprecated in 2.3. Remove this controller
 
-const auditLogger = auditLoggerFactory('abuse')
 const abuseVideoRouter = express.Router()
 
 abuseVideoRouter.get('/abuse',
   authenticate,
-  ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES),
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
   paginationValidator,
-  videoAbusesSortValidator,
+  abusesSortValidator,
   setDefaultSort,
   setDefaultPagination,
   videoAbuseListValidator,
@@ -41,7 +35,7 @@ abuseVideoRouter.get('/abuse',
 )
 abuseVideoRouter.put('/:videoId/abuse/:id',
   authenticate,
-  ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES),
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
   asyncMiddleware(videoAbuseUpdateValidator),
   asyncRetryTransactionMiddleware(updateVideoAbuse)
 )
@@ -52,7 +46,7 @@ abuseVideoRouter.post('/:videoId/abuse',
 )
 abuseVideoRouter.delete('/:videoId/abuse/:id',
   authenticate,
-  ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES),
+  ensureUserHasRight(UserRight.MANAGE_ABUSES),
   asyncMiddleware(videoAbuseGetValidator),
   asyncRetryTransactionMiddleware(deleteVideoAbuse)
 )
@@ -69,11 +63,12 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.user
   const serverActor = await getServerActor()
 
-  const resultList = await VideoAbuseModel.listForApi({
+  const resultList = await AbuseModel.listForApi({
     start: req.query.start,
     count: req.query.count,
     sort: req.query.sort,
     id: req.query.id,
+    filter: 'video',
     predefinedReason: req.query.predefinedReason,
     search: req.query.search,
     state: req.query.state,
@@ -90,74 +85,28 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
 }
 
 async function updateVideoAbuse (req: express.Request, res: express.Response) {
-  const videoAbuse = res.locals.videoAbuse
-
-  if (req.body.moderationComment !== undefined) videoAbuse.moderationComment = req.body.moderationComment
-  if (req.body.state !== undefined) videoAbuse.state = req.body.state
-
-  await sequelizeTypescript.transaction(t => {
-    return videoAbuse.save({ transaction: t })
-  })
-
-  // Do not send the delete to other instances, we updated OUR copy of this video abuse
-
-  return res.type('json').status(204).end()
+  return updateAbuse(req, res)
 }
 
 async function deleteVideoAbuse (req: express.Request, res: express.Response) {
-  const videoAbuse = res.locals.videoAbuse
-
-  await sequelizeTypescript.transaction(t => {
-    return videoAbuse.destroy({ transaction: t })
-  })
-
-  // Do not send the delete to other instances, we delete OUR copy of this video abuse
-
-  return res.type('json').status(204).end()
+  return deleteAbuse(req, res)
 }
 
 async function reportVideoAbuse (req: express.Request, res: express.Response) {
-  const videoInstance = res.locals.videoAll
-  const body: VideoAbuseCreate = req.body
-  let reporterAccount: MAccountDefault
-  let videoAbuseJSON: VideoAbuse
-
-  const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
-    reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
-    const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r])
-
-    const abuseToCreate = {
-      reporterAccountId: reporterAccount.id,
-      reason: body.reason,
-      videoId: videoInstance.id,
-      state: VideoAbuseState.PENDING,
-      predefinedReasons,
-      startAt: body.startAt,
-      endAt: body.endAt
-    }
-
-    const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t })
-    videoAbuseInstance.Video = videoInstance
-    videoAbuseInstance.Account = reporterAccount
-
-    // We send the video abuse to the origin server
-    if (videoInstance.isOwned() === false) {
-      await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance, t)
-    }
+  const oldBody = req.body as VideoAbuseCreate
 
-    videoAbuseJSON = videoAbuseInstance.toFormattedJSON()
-    auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseJSON))
+  req.body = {
+    accountId: res.locals.videoAll.VideoChannel.accountId,
 
-    return videoAbuseInstance
-  })
+    reason: oldBody.reason,
+    predefinedReasons: oldBody.predefinedReasons,
 
-  Notifier.Instance.notifyOnNewVideoAbuse({
-    videoAbuse: videoAbuseJSON,
-    videoAbuseInstance,
-    reporter: reporterAccount.Actor.getIdentifier()
-  })
-
-  logger.info('Abuse report for video "%s" created.', videoInstance.name)
+    video: {
+      id: res.locals.videoAll.id,
+      startAt: oldBody.startAt,
+      endAt: oldBody.endAt
+    }
+  } as AbuseCreate
 
-  return res.json({ videoAbuse: videoAbuseJSON }).end()
+  return reportAbuse(req, res)
 }
index 0bbfbc753e559a3ba89465ff18d43aac6e4268a7..954b0b69da8a3abe9666c3484c562d8712d38092 100644 (file)
@@ -1,15 +1,15 @@
-import * as path from 'path'
-import * as express from 'express'
 import { diff } from 'deep-object-diff'
-import { chain } from 'lodash'
+import * as express from 'express'
 import * as flatten from 'flat'
+import { chain } from 'lodash'
+import * as path from 'path'
 import * as winston from 'winston'
-import { jsonLoggerFormat, labelFormatter } from './logger'
-import { User, VideoAbuse, VideoChannel, VideoDetails, VideoImport } from '../../shared'
-import { VideoComment } from '../../shared/models/videos/video-comment.model'
+import { AUDIT_LOG_FILENAME } from '@server/initializers/constants'
+import { Abuse, User, VideoChannel, VideoDetails, VideoImport } from '../../shared'
 import { CustomConfig } from '../../shared/models/server/custom-config.model'
+import { VideoComment } from '../../shared/models/videos/video-comment.model'
 import { CONFIG } from '../initializers/config'
-import { AUDIT_LOG_FILENAME } from '@server/initializers/constants'
+import { jsonLoggerFormat, labelFormatter } from './logger'
 
 function getAuditIdFromRes (res: express.Response) {
   return res.locals.oauth.token.User.username
@@ -212,18 +212,15 @@ class VideoChannelAuditView extends EntityAuditView {
   }
 }
 
-const videoAbuseKeysToKeep = [
+const abuseKeysToKeep = [
   'id',
   'reason',
   'reporterAccount',
-  'video-id',
-  'video-name',
-  'video-uuid',
   'createdAt'
 ]
-class VideoAbuseAuditView extends EntityAuditView {
-  constructor (private readonly videoAbuse: VideoAbuse) {
-    super(videoAbuseKeysToKeep, 'abuse', videoAbuse)
+class AbuseAuditView extends EntityAuditView {
+  constructor (private readonly abuse: Abuse) {
+    super(abuseKeysToKeep, 'abuse', abuse)
   }
 }
 
@@ -274,6 +271,6 @@ export {
   CommentAuditView,
   UserAuditView,
   VideoAuditView,
-  VideoAbuseAuditView,
+  AbuseAuditView,
   CustomConfigAuditView
 }
diff --git a/server/helpers/custom-validators/abuses.ts b/server/helpers/custom-validators/abuses.ts
new file mode 100644 (file)
index 0000000..0ca06a2
--- /dev/null
@@ -0,0 +1,61 @@
+import validator from 'validator'
+import { AbuseFilter, abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseVideoIs, AbuseCreate } from '@shared/models'
+import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
+import { exists, isArray } from './misc'
+
+const ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.ABUSES
+
+function isAbuseReasonValid (value: string) {
+  return exists(value) && validator.isLength(value, ABUSES_CONSTRAINTS_FIELDS.REASON)
+}
+
+function isAbusePredefinedReasonValid (value: AbusePredefinedReasonsString) {
+  return exists(value) && value in abusePredefinedReasonsMap
+}
+
+function isAbuseFilterValid (value: AbuseFilter) {
+  return value === 'video' || value === 'comment' || value === 'account'
+}
+
+function areAbusePredefinedReasonsValid (value: AbusePredefinedReasonsString[]) {
+  return exists(value) && isArray(value) && value.every(v => v in abusePredefinedReasonsMap)
+}
+
+function isAbuseTimestampValid (value: number) {
+  return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
+}
+
+function isAbuseTimestampCoherent (endAt: number, { req }) {
+  const startAt = (req.body as AbuseCreate).video.startAt
+
+  return exists(startAt) && endAt > startAt
+}
+
+function isAbuseModerationCommentValid (value: string) {
+  return exists(value) && validator.isLength(value, ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
+}
+
+function isAbuseStateValid (value: string) {
+  return exists(value) && ABUSE_STATES[value] !== undefined
+}
+
+function isAbuseVideoIsValid (value: AbuseVideoIs) {
+  return exists(value) && (
+    value === 'deleted' ||
+    value === 'blacklisted'
+  )
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isAbuseReasonValid,
+  isAbuseFilterValid,
+  isAbusePredefinedReasonValid,
+  areAbusePredefinedReasonsValid as isAbusePredefinedReasonsValid,
+  isAbuseTimestampValid,
+  isAbuseTimestampCoherent,
+  isAbuseModerationCommentValid,
+  isAbuseStateValid,
+  isAbuseVideoIsValid
+}
index 6452e297cb9322af39d48f2fdfa39f25827a119d..dc90b366702ddc755f9658d726075ea9c6ddbee1 100644 (file)
@@ -1,9 +1,9 @@
 import { isActivityPubUrlValid } from './misc'
-import { isVideoAbuseReasonValid } from '../video-abuses'
+import { isAbuseReasonValid } from '../abuses'
 
 function isFlagActivityValid (activity: any) {
   return activity.type === 'Flag' &&
-    isVideoAbuseReasonValid(activity.content) &&
+    isAbuseReasonValid(activity.content) &&
     isActivityPubUrlValid(activity.object)
 }
 
diff --git a/server/helpers/custom-validators/video-abuses.ts b/server/helpers/custom-validators/video-abuses.ts
deleted file mode 100644 (file)
index 0c2c342..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-import validator from 'validator'
-
-import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
-import { exists, isArray } from './misc'
-import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
-import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
-
-const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
-
-function isVideoAbuseReasonValid (value: string) {
-  return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON)
-}
-
-function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) {
-  return exists(value) && value in videoAbusePredefinedReasonsMap
-}
-
-function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) {
-  return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap)
-}
-
-function isVideoAbuseTimestampValid (value: number) {
-  return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
-}
-
-function isVideoAbuseTimestampCoherent (endAt: number, { req }) {
-  return exists(req.body.startAt) && endAt > req.body.startAt
-}
-
-function isVideoAbuseModerationCommentValid (value: string) {
-  return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
-}
-
-function isVideoAbuseStateValid (value: string) {
-  return exists(value) && VIDEO_ABUSE_STATES[value] !== undefined
-}
-
-function isAbuseVideoIsValid (value: VideoAbuseVideoIs) {
-  return exists(value) && (
-    value === 'deleted' ||
-    value === 'blacklisted'
-  )
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  isVideoAbuseReasonValid,
-  isVideoAbusePredefinedReasonValid,
-  isVideoAbusePredefinedReasonsValid,
-  isVideoAbuseTimestampValid,
-  isVideoAbuseTimestampCoherent,
-  isVideoAbuseModerationCommentValid,
-  isVideoAbuseStateValid,
-  isAbuseVideoIsValid
-}
index 846f28b17ad744ab6ed011e504041f458a7b43f2..455ff424110442bfd166554ba41d4ef5cba8b278 100644 (file)
@@ -1,6 +1,8 @@
-import 'multer'
+import * as express from 'express'
 import validator from 'validator'
+import { VideoCommentModel } from '@server/models/video/video-comment'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
+import { MVideoId } from '@server/types/models'
 
 const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS
 
@@ -8,8 +10,83 @@ function isValidVideoCommentText (value: string) {
   return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT)
 }
 
+async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
+  const id = parseInt(idArg + '', 10)
+  const videoComment = await VideoCommentModel.loadById(id)
+
+  if (!videoComment) {
+    res.status(404)
+      .json({ error: 'Video comment thread not found' })
+      .end()
+
+    return false
+  }
+
+  if (videoComment.videoId !== video.id) {
+    res.status(400)
+      .json({ error: 'Video comment is not associated to this video.' })
+      .end()
+
+    return false
+  }
+
+  if (videoComment.inReplyToCommentId !== null) {
+    res.status(400)
+      .json({ error: 'Video comment is not a thread.' })
+      .end()
+
+    return false
+  }
+
+  res.locals.videoCommentThread = videoComment
+  return true
+}
+
+async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
+  const id = parseInt(idArg + '', 10)
+  const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
+
+  if (!videoComment) {
+    res.status(404)
+      .json({ error: 'Video comment thread not found' })
+      .end()
+
+    return false
+  }
+
+  if (videoComment.videoId !== video.id) {
+    res.status(400)
+      .json({ error: 'Video comment is not associated to this video.' })
+      .end()
+
+    return false
+  }
+
+  res.locals.videoCommentFull = videoComment
+  return true
+}
+
+async function doesCommentIdExist (idArg: number | string, res: express.Response) {
+  const id = parseInt(idArg + '', 10)
+  const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
+
+  if (!videoComment) {
+    res.status(404)
+      .json({ error: 'Video comment thread not found' })
+
+    return false
+  }
+
+  res.locals.videoCommentFull = videoComment
+
+  return true
+}
+
 // ---------------------------------------------------------------------------
 
 export {
-  isValidVideoCommentText
+  isValidVideoCommentText,
+  doesVideoCommentThreadExist,
+  doesVideoCommentExist,
+  doesCommentIdExist
 }
diff --git a/server/helpers/middlewares/abuses.ts b/server/helpers/middlewares/abuses.ts
new file mode 100644 (file)
index 0000000..be8c8b4
--- /dev/null
@@ -0,0 +1,47 @@
+import { Response } from 'express'
+import { AbuseModel } from '../../models/abuse/abuse'
+import { fetchVideo } from '../video'
+
+// FIXME: deprecated in 2.3. Remove this function
+async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
+  const abuseId = parseInt(abuseIdArg + '', 10)
+  let abuse = await AbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
+
+  if (!abuse) {
+    const userId = res.locals.oauth?.token.User.id
+    const video = await fetchVideo(videoUUID, 'all', userId)
+
+    if (video) abuse = await AbuseModel.loadByIdAndVideoId(abuseId, video.id)
+  }
+
+  if (abuse === null) {
+    res.status(404)
+       .json({ error: 'Video abuse not found' })
+
+    return false
+  }
+
+  res.locals.abuse = abuse
+  return true
+}
+
+async function doesAbuseExist (abuseId: number | string, res: Response) {
+  const abuse = await AbuseModel.loadById(parseInt(abuseId + '', 10))
+
+  if (!abuse) {
+    res.status(404)
+       .json({ error: 'Abuse not found' })
+
+    return false
+  }
+
+  res.locals.abuse = abuse
+  return true
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  doesAbuseExist,
+  doesVideoAbuseExist
+}
index bddea7eaa3f12326be09ba401da79fbdc45b2330..29b4ed1a6f56c737210f85817f5c6b97575df069 100644 (file)
@@ -3,8 +3,8 @@ import { AccountModel } from '../../models/account/account'
 import * as Bluebird from 'bluebird'
 import { MAccountDefault } from '../../types/models'
 
-function doesAccountIdExist (id: number, res: Response, sendNotFound = true) {
-  const promise = AccountModel.load(id)
+function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
+  const promise = AccountModel.load(parseInt(id + '', 10))
 
   return doesAccountExist(promise, res, sendNotFound)
 }
index f91aeaa1259417dd3ec2c3cd28dc6199a06fb277..f57f3ad310f797d2d314e1c16fa2625da893ca81 100644 (file)
@@ -1,5 +1,5 @@
+export * from './abuses'
 export * from './accounts'
-export * from './video-abuses'
 export * from './video-blacklists'
 export * from './video-captions'
 export * from './video-channels'
diff --git a/server/helpers/middlewares/video-abuses.ts b/server/helpers/middlewares/video-abuses.ts
deleted file mode 100644 (file)
index 97a5724..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Response } from 'express'
-import { VideoAbuseModel } from '../../models/video/video-abuse'
-import { fetchVideo } from '../video'
-
-async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
-  const abuseId = parseInt(abuseIdArg + '', 10)
-  let videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
-
-  if (!videoAbuse) {
-    const userId = res.locals.oauth?.token.User.id
-    const video = await fetchVideo(videoUUID, 'all', userId)
-
-    if (video) videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, video.id)
-  }
-
-  if (videoAbuse === null) {
-    res.status(404)
-       .json({ error: 'Video abuse not found' })
-       .end()
-
-    return false
-  }
-
-  res.locals.videoAbuse = videoAbuse
-  return true
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  doesVideoAbuseExist
-}
index e730e3c84080ddff3ef105e93892d2d773ae83dd..2e9d3956ea52e88c4cefe2ce8806d004bb397eb6 100644 (file)
@@ -1,9 +1,17 @@
 import { join } from 'path'
 import { randomBytes } from 'crypto'
-import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models'
 import { ActivityPubActorType } from '../../shared/models/activitypub'
 import { FollowState } from '../../shared/models/actors'
-import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos'
+import {
+  AbuseState,
+  VideoImportState,
+  VideoPrivacy,
+  VideoTranscodingFPS,
+  JobType,
+  VideoRateType,
+  VideoResolution,
+  VideoState
+} from '../../shared/models'
 // Do not use barrels, remain constants as independent as possible
 import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils'
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@@ -15,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 515
+const LAST_MIGRATION_VERSION = 520
 
 // ---------------------------------------------------------------------------
 
@@ -51,7 +59,6 @@ const SORTABLE_COLUMNS = {
   USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
   ACCOUNTS: [ 'createdAt' ],
   JOBS: [ 'createdAt' ],
-  VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ],
   VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
   VIDEO_IMPORTS: [ 'createdAt' ],
   VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
@@ -66,6 +73,8 @@ const SORTABLE_COLUMNS = {
   VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
   VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
 
+  ABUSES: [ 'id', 'createdAt', 'state' ],
+
   ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
   SERVERS_BLOCKLIST: [ 'createdAt' ],
 
@@ -193,7 +202,7 @@ const CONSTRAINTS_FIELDS = {
     VIDEO_LANGUAGES: { max: 500 }, // Array length
     BLOCKED_REASON: { min: 3, max: 250 } // Length
   },
-  VIDEO_ABUSES: {
+  ABUSES: {
     REASON: { min: 2, max: 3000 }, // Length
     MODERATION_COMMENT: { min: 2, max: 3000 } // Length
   },
@@ -378,10 +387,10 @@ const VIDEO_IMPORT_STATES = {
   [VideoImportState.REJECTED]: 'Rejected'
 }
 
-const VIDEO_ABUSE_STATES = {
-  [VideoAbuseState.PENDING]: 'Pending',
-  [VideoAbuseState.REJECTED]: 'Rejected',
-  [VideoAbuseState.ACCEPTED]: 'Accepted'
+const ABUSE_STATES = {
+  [AbuseState.PENDING]: 'Pending',
+  [AbuseState.REJECTED]: 'Rejected',
+  [AbuseState.ACCEPTED]: 'Accepted'
 }
 
 const VIDEO_PLAYLIST_PRIVACIES = {
@@ -778,7 +787,7 @@ export {
   VIDEO_RATE_TYPES,
   VIDEO_TRANSCODING_FPS,
   FFMPEG_NICE,
-  VIDEO_ABUSE_STATES,
+  ABUSE_STATES,
   VIDEO_CHANNELS,
   LRU_CACHE,
   JOB_REQUEST_TIMEOUT,
index 633d4f95645eac497265a5ccb32cbd06e8448caf..0775f1fadc75ed56ede1826f62ee7cc440821c00 100644 (file)
@@ -1,44 +1,45 @@
+import { QueryTypes, Transaction } from 'sequelize'
 import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
+import { AbuseModel } from '@server/models/abuse/abuse'
+import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
+import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
 import { isTestInstance } from '../helpers/core-utils'
 import { logger } from '../helpers/logger'
-
 import { AccountModel } from '../models/account/account'
+import { AccountBlocklistModel } from '../models/account/account-blocklist'
 import { AccountVideoRateModel } from '../models/account/account-video-rate'
 import { UserModel } from '../models/account/user'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
+import { UserVideoHistoryModel } from '../models/account/user-video-history'
 import { ActorModel } from '../models/activitypub/actor'
 import { ActorFollowModel } from '../models/activitypub/actor-follow'
 import { ApplicationModel } from '../models/application/application'
 import { AvatarModel } from '../models/avatar/avatar'
 import { OAuthClientModel } from '../models/oauth/oauth-client'
 import { OAuthTokenModel } from '../models/oauth/oauth-token'
+import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
+import { PluginModel } from '../models/server/plugin'
 import { ServerModel } from '../models/server/server'
+import { ServerBlocklistModel } from '../models/server/server-blocklist'
+import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
 import { TagModel } from '../models/video/tag'
+import { ThumbnailModel } from '../models/video/thumbnail'
 import { VideoModel } from '../models/video/video'
-import { VideoAbuseModel } from '../models/video/video-abuse'
 import { VideoBlacklistModel } from '../models/video/video-blacklist'
+import { VideoCaptionModel } from '../models/video/video-caption'
+import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
 import { VideoChannelModel } from '../models/video/video-channel'
 import { VideoCommentModel } from '../models/video/video-comment'
 import { VideoFileModel } from '../models/video/video-file'
-import { VideoShareModel } from '../models/video/video-share'
-import { VideoTagModel } from '../models/video/video-tag'
-import { CONFIG } from './config'
-import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
-import { VideoCaptionModel } from '../models/video/video-caption'
 import { VideoImportModel } from '../models/video/video-import'
-import { VideoViewModel } from '../models/video/video-view'
-import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership'
-import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
-import { UserVideoHistoryModel } from '../models/account/user-video-history'
-import { AccountBlocklistModel } from '../models/account/account-blocklist'
-import { ServerBlocklistModel } from '../models/server/server-blocklist'
-import { UserNotificationModel } from '../models/account/user-notification'
-import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
-import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
 import { VideoPlaylistModel } from '../models/video/video-playlist'
 import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
-import { ThumbnailModel } from '../models/video/thumbnail'
-import { PluginModel } from '../models/server/plugin'
-import { QueryTypes, Transaction } from 'sequelize'
+import { VideoShareModel } from '../models/video/video-share'
+import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
+import { VideoTagModel } from '../models/video/video-tag'
+import { VideoViewModel } from '../models/video/video-view'
+import { CONFIG } from './config'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -86,6 +87,8 @@ async function initDatabaseModels (silent: boolean) {
     TagModel,
     AccountVideoRateModel,
     UserModel,
+    AbuseModel,
+    VideoCommentAbuseModel,
     VideoAbuseModel,
     VideoModel,
     VideoChangeOwnershipModel,
index 50de25182499bce398ab1b7bde221ec62a3a153d..e4993c393b978ccbca2e2a76b163be71fc3a7baf 100644 (file)
@@ -1,5 +1,5 @@
 import * as Sequelize from 'sequelize'
-import { VideoAbuseState } from '../../../shared/models/videos'
+import { AbuseState } from '../../../shared/models'
 
 async function up (utils: {
   transaction: Sequelize.Transaction
@@ -16,7 +16,7 @@ async function up (utils: {
   }
 
   {
-    const query = 'UPDATE "videoAbuse" SET "state" = ' + VideoAbuseState.PENDING
+    const query = 'UPDATE "videoAbuse" SET "state" = ' + AbuseState.PENDING
     await utils.sequelize.query(query)
   }
 
diff --git a/server/initializers/migrations/0520-abuses-split.ts b/server/initializers/migrations/0520-abuses-split.ts
new file mode 100644 (file)
index 0000000..b02a219
--- /dev/null
@@ -0,0 +1,90 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+  await utils.queryInterface.renameTable('videoAbuse', 'abuse')
+
+  await utils.sequelize.query(`
+    ALTER TABLE "abuse"
+    ADD COLUMN "flaggedAccountId" INTEGER REFERENCES "account" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+  `)
+
+  await utils.sequelize.query(`
+    UPDATE "abuse" SET "videoId" = NULL
+    WHERE "videoId" NOT IN (SELECT "id" FROM "video")
+  `)
+
+  await utils.sequelize.query(`
+    UPDATE "abuse" SET "flaggedAccountId" = "videoChannel"."accountId"
+    FROM "video" INNER JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"
+    WHERE "abuse"."videoId" = "video"."id"
+  `)
+
+  await utils.sequelize.query('DROP INDEX IF EXISTS video_abuse_video_id;')
+  await utils.sequelize.query('DROP INDEX IF EXISTS video_abuse_reporter_account_id;')
+
+  await utils.sequelize.query(`
+    CREATE TABLE IF NOT EXISTS "videoAbuse" (
+      "id" serial,
+      "startAt" integer DEFAULT NULL,
+      "endAt" integer DEFAULT NULL,
+      "deletedVideo" jsonb DEFAULT NULL,
+      "abuseId" integer NOT NULL REFERENCES "abuse" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+      "videoId" integer REFERENCES "video" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+      "createdAt" TIMESTAMP WITH time zone NOT NULL,
+      "updatedAt" timestamp WITH time zone NOT NULL,
+      PRIMARY KEY ("id")
+    );
+  `)
+
+  await utils.sequelize.query(`
+    CREATE TABLE IF NOT EXISTS "commentAbuse" (
+      "id" serial,
+      "abuseId" integer NOT NULL REFERENCES "abuse" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+      "videoCommentId" integer REFERENCES "videoComment" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+      "createdAt" timestamp WITH time zone NOT NULL,
+      "updatedAt" timestamp WITH time zone NOT NULL,
+      PRIMARY KEY ("id")
+    );
+  `)
+
+  await utils.sequelize.query(`
+      INSERT INTO "videoAbuse" ("startAt", "endAt", "deletedVideo", "abuseId", "videoId", "createdAt", "updatedAt")
+      SELECT "abuse"."startAt", "abuse"."endAt", "abuse"."deletedVideo", "abuse"."id", "abuse"."videoId",
+      "abuse"."createdAt", "abuse"."updatedAt"
+      FROM "abuse"
+  `)
+
+  await utils.queryInterface.removeColumn('abuse', 'startAt')
+  await utils.queryInterface.removeColumn('abuse', 'endAt')
+  await utils.queryInterface.removeColumn('abuse', 'deletedVideo')
+  await utils.queryInterface.removeColumn('abuse', 'videoId')
+
+  await utils.sequelize.query('DROP INDEX IF EXISTS user_notification_video_abuse_id')
+  await utils.queryInterface.renameColumn('userNotification', 'videoAbuseId', 'abuseId')
+  await utils.sequelize.query(
+    'ALTER TABLE "userNotification" RENAME CONSTRAINT "userNotification_videoAbuseId_fkey" TO "userNotification_abuseId_fkey"'
+  )
+
+  await utils.sequelize.query(
+    'ALTER TABLE "abuse" RENAME CONSTRAINT "videoAbuse_reporterAccountId_fkey" TO "abuse_reporterAccountId_fkey"'
+  )
+
+  await utils.sequelize.query(
+    'ALTER INDEX IF EXISTS "videoAbuse_pkey" RENAME TO "abuse_pkey"'
+  )
+
+  await utils.queryInterface.renameColumn('userNotificationSetting', 'videoAbuseAsModerator', 'abuseAsModerator')
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 1d7132a3a7010f8a04ac94747ae12af42e20070f..6350cee12816e92f8277f168b40d10e9222d4375 100644 (file)
@@ -1,24 +1,19 @@
-import {
-  ActivityCreate,
-  ActivityFlag,
-  VideoAbuseState,
-  videoAbusePredefinedReasonsMap
-} from '../../../../shared'
-import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
+import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
+import { AccountModel } from '@server/models/account/account'
+import { VideoModel } from '@server/models/video/video'
+import { VideoCommentModel } from '@server/models/video/video-comment'
+import { AbuseObject, abusePredefinedReasonsMap, AbuseState, ActivityCreate, ActivityFlag } from '../../../../shared'
+import { getAPId } from '../../../helpers/activitypub'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { logger } from '../../../helpers/logger'
 import { sequelizeTypescript } from '../../../initializers/database'
-import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { getOrCreateVideoAndAccountAndChannel } from '../videos'
-import { Notifier } from '../../notifier'
-import { getAPId } from '../../../helpers/activitypub'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model'
-import { MActorSignature, MVideoAbuseAccountVideo } from '../../../types/models'
-import { AccountModel } from '@server/models/account/account'
+import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models'
 
 async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) {
   const { activity, byActor } = options
-  return retryTransactionWrapper(processCreateVideoAbuse, activity, byActor)
+
+  return retryTransactionWrapper(processCreateAbuse, activity, byActor)
 }
 
 // ---------------------------------------------------------------------------
@@ -29,55 +24,79 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) {
-  const flag = activity.type === 'Flag' ? activity : (activity.object as VideoAbuseObject)
+async function processCreateAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) {
+  const flag = activity.type === 'Flag' ? activity : (activity.object as AbuseObject)
 
   const account = byActor.Account
-  if (!account) throw new Error('Cannot create video abuse with the non account actor ' + byActor.url)
+  if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
+
+  const reporterAccount = await AccountModel.load(account.id)
 
   const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ]
 
+  const tags = Array.isArray(flag.tag) ? flag.tag : []
+  const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name])
+                                .filter(v => !isNaN(v))
+
+  const startAt = flag.startAt
+  const endAt = flag.endAt
+
   for (const object of objects) {
     try {
-      logger.debug('Reporting remote abuse for video %s.', getAPId(object))
-
-      const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object })
-      const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t))
-      const tags = Array.isArray(flag.tag) ? flag.tag : []
-      const predefinedReasons = tags.map(tag => videoAbusePredefinedReasonsMap[tag.name])
-                                    .filter(v => !isNaN(v))
-      const startAt = flag.startAt
-      const endAt = flag.endAt
-
-      const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
-        const videoAbuseData = {
-          reporterAccountId: account.id,
-          reason: flag.content,
-          videoId: video.id,
-          state: VideoAbuseState.PENDING,
-          predefinedReasons,
-          startAt,
-          endAt
-        }
+      const uri = getAPId(object)
 
-        const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
-        videoAbuseInstance.Video = video
-        videoAbuseInstance.Account = reporterAccount
+      logger.debug('Reporting remote abuse for object %s.', uri)
 
-        logger.info('Remote abuse for video uuid %s created', flag.object)
+      await sequelizeTypescript.transaction(async t => {
 
-        return videoAbuseInstance
-      })
+        const video = await VideoModel.loadByUrlAndPopulateAccount(uri)
+        let videoComment: MCommentOwnerVideo
+        let flaggedAccount: MAccountDefault
+
+        if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri)
+        if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri)
+
+        if (!video && !videoComment && !flaggedAccount) {
+          logger.warn('Cannot flag unknown entity %s.', object)
+          return
+        }
+
+        const baseAbuse = {
+          reporterAccountId: reporterAccount.id,
+          reason: flag.content,
+          state: AbuseState.PENDING,
+          predefinedReasons
+        }
 
-      const videoAbuseJSON = videoAbuseInstance.toFormattedJSON()
+        if (video) {
+          return createVideoAbuse({
+            baseAbuse,
+            startAt,
+            endAt,
+            reporterAccount,
+            transaction: t,
+            videoInstance: video
+          })
+        }
+
+        if (videoComment) {
+          return createVideoCommentAbuse({
+            baseAbuse,
+            reporterAccount,
+            transaction: t,
+            commentInstance: videoComment
+          })
+        }
 
-      Notifier.Instance.notifyOnNewVideoAbuse({
-        videoAbuse: videoAbuseJSON,
-        videoAbuseInstance,
-        reporter: reporterAccount.Actor.getIdentifier()
+        return await createAccountAbuse({
+          baseAbuse,
+          reporterAccount,
+          transaction: t,
+          accountInstance: flaggedAccount
+        })
       })
     } catch (err) {
-      logger.debug('Cannot process report of %s. (Maybe not a video abuse).', getAPId(object), { err })
+      logger.debug('Cannot process report of %s', getAPId(object), { err })
     }
   }
 }
index 3a1fe08122e95a5547eafffdc36b81bd290b9f19..821637ec84b871858b2d9f72a8c6971f44abef7f 100644 (file)
@@ -1,32 +1,31 @@
-import { getVideoAbuseActivityPubUrl } from '../url'
-import { unicastTo } from './utils'
-import { logger } from '../../../helpers/logger'
+import { Transaction } from 'sequelize'
 import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
+import { logger } from '../../../helpers/logger'
+import { MAbuseAP, MAccountLight, MActor } from '../../../types/models'
 import { audiencify, getAudience } from '../audience'
-import { Transaction } from 'sequelize'
-import { MActor, MVideoFullLight } from '../../../types/models'
-import { MVideoAbuseVideo } from '../../../types/models/video'
+import { getAbuseActivityPubUrl } from '../url'
+import { unicastTo } from './utils'
 
-function sendVideoAbuse (byActor: MActor, videoAbuse: MVideoAbuseVideo, video: MVideoFullLight, t: Transaction) {
-  if (!video.VideoChannel.Account.Actor.serverId) return // Local user
+function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
+  if (!flaggedAccount.Actor.serverId) return // Local user
 
-  const url = getVideoAbuseActivityPubUrl(videoAbuse)
+  const url = getAbuseActivityPubUrl(abuse)
 
-  logger.info('Creating job to send video abuse %s.', url)
+  logger.info('Creating job to send abuse %s.', url)
 
   // Custom audience, we only send the abuse to the origin instance
-  const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
-  const flagActivity = buildFlagActivity(url, byActor, videoAbuse, audience)
+  const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
+  const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
 
-  t.afterCommit(() => unicastTo(flagActivity, byActor, video.VideoChannel.Account.Actor.getSharedInbox()))
+  t.afterCommit(() => unicastTo(flagActivity, byActor, flaggedAccount.Actor.getSharedInbox()))
 }
 
-function buildFlagActivity (url: string, byActor: MActor, videoAbuse: MVideoAbuseVideo, audience: ActivityAudience): ActivityFlag {
+function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
   if (!audience) audience = getAudience(byActor)
 
   const activity = Object.assign(
     { id: url, actor: byActor.url },
-    videoAbuse.toActivityPubObject()
+    abuse.toActivityPubObject()
   )
 
   return audiencify(activity, audience)
@@ -35,5 +34,5 @@ function buildFlagActivity (url: string, byActor: MActor, videoAbuse: MVideoAbus
 // ---------------------------------------------------------------------------
 
 export {
-  sendVideoAbuse
+  sendAbuse
 }
index 7f98751a1263814abc42a7af7e9649f04b92252b..b54e038a43f5684f6080c62cf0e2ba69505b8a06 100644 (file)
@@ -5,10 +5,10 @@ import {
   MActorId,
   MActorUrl,
   MCommentId,
-  MVideoAbuseId,
   MVideoId,
   MVideoUrl,
-  MVideoUUID
+  MVideoUUID,
+  MAbuseId
 } from '../../types/models'
 import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist'
 import { MVideoFileVideoUUID } from '../../types/models/video/video-file'
@@ -48,8 +48,8 @@ function getAccountActivityPubUrl (accountName: string) {
   return WEBSERVER.URL + '/accounts/' + accountName
 }
 
-function getVideoAbuseActivityPubUrl (videoAbuse: MVideoAbuseId) {
-  return WEBSERVER.URL + '/admin/video-abuses/' + videoAbuse.id
+function getAbuseActivityPubUrl (abuse: MAbuseId) {
+  return WEBSERVER.URL + '/admin/abuses/' + abuse.id
 }
 
 function getVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) {
@@ -118,7 +118,7 @@ export {
   getVideoCacheStreamingPlaylistActivityPubUrl,
   getVideoChannelActivityPubUrl,
   getAccountActivityPubUrl,
-  getVideoAbuseActivityPubUrl,
+  getAbuseActivityPubUrl,
   getActorFollowActivityPubUrl,
   getActorFollowAcceptActivityPubUrl,
   getVideoAnnounceActivityPubUrl,
index c08732b4833de362798a3afeedb9a7dbeebea050..d54eab96664814db2ebde39f4dad407dc4213de9 100644 (file)
@@ -1,26 +1,20 @@
+import { readFileSync } from 'fs-extra'
+import { merge } from 'lodash'
 import { createTransport, Transporter } from 'nodemailer'
+import { join } from 'path'
+import { VideoChannelModel } from '@server/models/video/video-channel'
+import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
+import { MVideoImport, MVideoImportVideo } from '@server/types/models/video/video-import'
+import { Abuse, EmailPayload } from '@shared/models'
+import { SendEmailOptions } from '../../shared/models/server/emailer.model'
 import { isTestInstance, root } from '../helpers/core-utils'
 import { bunyanLogger, logger } from '../helpers/logger'
 import { CONFIG, isEmailEnabled } from '../initializers/config'
-import { JobQueue } from './job-queue'
-import { readFileSync } from 'fs-extra'
 import { WEBSERVER } from '../initializers/constants'
-import {
-  MCommentOwnerVideo,
-  MVideo,
-  MVideoAbuseVideo,
-  MVideoAccountLight,
-  MVideoBlacklistLightVideo,
-  MVideoBlacklistVideo
-} from '../types/models/video'
-import { MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
-import { MVideoImport, MVideoImportVideo } from '@server/types/models/video/video-import'
-import { EmailPayload } from '@shared/models'
-import { join } from 'path'
-import { VideoAbuse } from '../../shared/models/videos'
-import { SendEmailOptions } from '../../shared/models/server/emailer.model'
-import { merge } from 'lodash'
-import { VideoChannelModel } from '@server/models/video/video-channel'
+import { MAbuseFull, MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
+import { MCommentOwnerVideo, MVideo, MVideoAccountLight } from '../types/models/video'
+import { JobQueue } from './job-queue'
+
 const Email = require('email-templates')
 
 class Emailer {
@@ -288,28 +282,74 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
-  addVideoAbuseModeratorsNotification (to: string[], parameters: {
-    videoAbuse: VideoAbuse
-    videoAbuseInstance: MVideoAbuseVideo
+  addAbuseModeratorsNotification (to: string[], parameters: {
+    abuse: Abuse
+    abuseInstance: MAbuseFull
     reporter: string
   }) {
-    const videoAbuseUrl = WEBSERVER.URL + '/admin/moderation/video-abuses/list?search=%23' + parameters.videoAbuse.id
-    const videoUrl = WEBSERVER.URL + parameters.videoAbuseInstance.Video.getWatchStaticPath()
+    const { abuse, abuseInstance, reporter } = parameters
 
-    const emailPayload: EmailPayload = {
-      template: 'video-abuse-new',
-      to,
-      subject: `New video abuse report from ${parameters.reporter}`,
-      locals: {
-        videoUrl,
-        videoAbuseUrl,
-        videoCreatedAt: new Date(parameters.videoAbuseInstance.Video.createdAt).toLocaleString(),
-        videoPublishedAt: new Date(parameters.videoAbuseInstance.Video.publishedAt).toLocaleString(),
-        videoAbuse: parameters.videoAbuse,
-        reporter: parameters.reporter,
-        action: {
-          text: 'View report #' + parameters.videoAbuse.id,
-          url: videoAbuseUrl
+    const action = {
+      text: 'View report #' + abuse.id,
+      url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
+    }
+
+    let emailPayload: EmailPayload
+
+    if (abuseInstance.VideoAbuse) {
+      const video = abuseInstance.VideoAbuse.Video
+      const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
+
+      emailPayload = {
+        template: 'video-abuse-new',
+        to,
+        subject: `New video abuse report from ${reporter}`,
+        locals: {
+          videoUrl,
+          isLocal: video.remote === false,
+          videoCreatedAt: new Date(video.createdAt).toLocaleString(),
+          videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
+          videoName: video.name,
+          reason: abuse.reason,
+          videoChannel: abuse.video.channel,
+          reporter,
+          action
+        }
+      }
+    } else if (abuseInstance.VideoCommentAbuse) {
+      const comment = abuseInstance.VideoCommentAbuse.VideoComment
+      const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
+
+      emailPayload = {
+        template: 'video-comment-abuse-new',
+        to,
+        subject: `New comment abuse report from ${reporter}`,
+        locals: {
+          commentUrl,
+          videoName: comment.Video.name,
+          isLocal: comment.isOwned(),
+          commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
+          reason: abuse.reason,
+          flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(),
+          reporter,
+          action
+        }
+      }
+    } else {
+      const account = abuseInstance.FlaggedAccount
+      const accountUrl = account.getClientUrl()
+
+      emailPayload = {
+        template: 'account-abuse-new',
+        to,
+        subject: `New account abuse report from ${reporter}`,
+        locals: {
+          accountUrl,
+          accountDisplayName: account.getDisplayName(),
+          isLocal: account.isOwned(),
+          reason: abuse.reason,
+          reporter,
+          action
         }
       }
     }
diff --git a/server/lib/emails/account-abuse-new/html.pug b/server/lib/emails/account-abuse-new/html.pug
new file mode 100644 (file)
index 0000000..f1aa288
--- /dev/null
@@ -0,0 +1,14 @@
+extends ../common/greetings
+include ../common/mixins.pug
+
+block title
+  | An account is pending moderation
+
+block content
+  p
+    | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}account
+    a(href=accountUrl)  #{accountDisplayName}
+
+  p The reporter, #{reporter}, cited the following reason(s):
+  blockquote #{reason}
+  br(style="display: none;")
index 76b805a24f2d9ca0737b09083d43b426ce286dd1..8312118643d0f8426076fb2bacb3d62f992314a1 100644 (file)
@@ -1,3 +1,7 @@
 mixin channel(channel)
   - var handle = `${channel.name}@${channel.host}`
-  | #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]
\ No newline at end of file
+  | #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]
+
+mixin account(account)
+  - var handle = `${account.name}@${account.host}`
+  | #[a(href=`${WEBSERVER.URL}/accounts/${handle}` title=handle) #{account.displayName}]
index 999c89d26e07e14684a6ac71ad8f72c7f5c26129..a1acdabdccdacdb569743897dc1c55513ae2eac0 100644 (file)
@@ -6,13 +6,13 @@ block title
 
 block content
   p
-    | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{videoAbuse.video.channel.isLocal ? '' : 'remote '}video "
-    a(href=videoUrl) #{videoAbuse.video.name}
-    | " by #[+channel(videoAbuse.video.channel)]
+    | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}video "
+    a(href=videoUrl) #{videoName}
+    | " by #[+channel(videoChannel)]
     if videoPublishedAt
       | , published the #{videoPublishedAt}.
     else
       | , uploaded the #{videoCreatedAt} but not yet published.
   p The reporter, #{reporter}, cited the following reason(s):
-  blockquote #{videoAbuse.reason}
+  blockquote #{reason}
   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 (file)
index 0000000..e92d986
--- /dev/null
@@ -0,0 +1,16 @@
+extends ../common/greetings
+include ../common/mixins.pug
+
+block title
+  | A comment is pending moderation
+
+block content
+  p
+    | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}
+    a(href=commentUrl) comment on video "#{videoName}"
+    |  of #{flaggedAccount}
+    |  created on #{commentCreatedAt}
+
+  p The reporter, #{reporter}, cited the following reason(s):
+  blockquote #{reason}
+  br(style="display: none;")
index 60d1b40537f3a88f53efe8e1922f059dced1f06f..4fc9cd747609a22d0d612caafc8e6c1fb944b566 100644 (file)
@@ -1,15 +1,33 @@
-import { VideoModel } from '../models/video/video'
-import { VideoCommentModel } from '../models/video/video-comment'
-import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
+import { PathLike } from 'fs-extra'
+import { Transaction } from 'sequelize/types'
+import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger'
+import { logger } from '@server/helpers/logger'
+import { AbuseModel } from '@server/models/abuse/abuse'
+import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
+import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
+import { VideoFileModel } from '@server/models/video/video-file'
+import { FilteredModelAttributes } from '@server/types'
+import {
+  MAbuseFull,
+  MAccountDefault,
+  MAccountLight,
+  MCommentAbuseAccountVideo,
+  MCommentOwnerVideo,
+  MUser,
+  MVideoAbuseVideoFull,
+  MVideoAccountLightBlacklistAllFiles
+} from '@server/types/models'
+import { ActivityCreate } from '../../shared/models/activitypub'
+import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
+import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
 import { VideoCreate, VideoImportCreate } from '../../shared/models/videos'
+import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
 import { UserModel } from '../models/account/user'
-import { VideoTorrentObject } from '../../shared/models/activitypub/objects'
-import { ActivityCreate } from '../../shared/models/activitypub'
 import { ActorModel } from '../models/activitypub/actor'
-import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
-import { VideoFileModel } from '@server/models/video/video-file'
-import { PathLike } from 'fs-extra'
-import { MUser } from '@server/types/models'
+import { VideoModel } from '../models/video/video'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { sendAbuse } from './activitypub/send/send-flag'
+import { Notifier } from './notifier'
 
 export type AcceptResult = {
   accepted: boolean
@@ -73,6 +91,89 @@ function isPostImportVideoAccepted (object: {
   return { accepted: true }
 }
 
+async function createVideoAbuse (options: {
+  baseAbuse: FilteredModelAttributes<AbuseModel>
+  videoInstance: MVideoAccountLightBlacklistAllFiles
+  startAt: number
+  endAt: number
+  transaction: Transaction
+  reporterAccount: MAccountDefault
+}) {
+  const { baseAbuse, videoInstance, startAt, endAt, transaction, reporterAccount } = options
+
+  const associateFun = async (abuseInstance: MAbuseFull) => {
+    const videoAbuseInstance: MVideoAbuseVideoFull = await VideoAbuseModel.create({
+      abuseId: abuseInstance.id,
+      videoId: videoInstance.id,
+      startAt: startAt,
+      endAt: endAt
+    }, { transaction })
+
+    videoAbuseInstance.Video = videoInstance
+    abuseInstance.VideoAbuse = videoAbuseInstance
+
+    return { isOwned: videoInstance.isOwned() }
+  }
+
+  return createAbuse({
+    base: baseAbuse,
+    reporterAccount,
+    flaggedAccount: videoInstance.VideoChannel.Account,
+    transaction,
+    associateFun
+  })
+}
+
+function createVideoCommentAbuse (options: {
+  baseAbuse: FilteredModelAttributes<AbuseModel>
+  commentInstance: MCommentOwnerVideo
+  transaction: Transaction
+  reporterAccount: MAccountDefault
+}) {
+  const { baseAbuse, commentInstance, transaction, reporterAccount } = options
+
+  const associateFun = async (abuseInstance: MAbuseFull) => {
+    const commentAbuseInstance: MCommentAbuseAccountVideo = await VideoCommentAbuseModel.create({
+      abuseId: abuseInstance.id,
+      videoCommentId: commentInstance.id
+    }, { transaction })
+
+    commentAbuseInstance.VideoComment = commentInstance
+    abuseInstance.VideoCommentAbuse = commentAbuseInstance
+
+    return { isOwned: commentInstance.isOwned() }
+  }
+
+  return createAbuse({
+    base: baseAbuse,
+    reporterAccount,
+    flaggedAccount: commentInstance.Account,
+    transaction,
+    associateFun
+  })
+}
+
+function createAccountAbuse (options: {
+  baseAbuse: FilteredModelAttributes<AbuseModel>
+  accountInstance: MAccountDefault
+  transaction: Transaction
+  reporterAccount: MAccountDefault
+}) {
+  const { baseAbuse, accountInstance, transaction, reporterAccount } = options
+
+  const associateFun = async () => {
+    return { isOwned: accountInstance.isOwned() }
+  }
+
+  return createAbuse({
+    base: baseAbuse,
+    reporterAccount,
+    flaggedAccount: accountInstance,
+    transaction,
+    associateFun
+  })
+}
+
 export {
   isLocalVideoAccepted,
   isLocalVideoThreadAccepted,
@@ -80,5 +181,48 @@ export {
   isRemoteVideoCommentAccepted,
   isLocalVideoCommentReplyAccepted,
   isPreImportVideoAccepted,
-  isPostImportVideoAccepted
+  isPostImportVideoAccepted,
+
+  createAbuse,
+  createVideoAbuse,
+  createVideoCommentAbuse,
+  createAccountAbuse
+}
+
+// ---------------------------------------------------------------------------
+
+async function createAbuse (options: {
+  base: FilteredModelAttributes<AbuseModel>
+  reporterAccount: MAccountDefault
+  flaggedAccount: MAccountLight
+  associateFun: (abuseInstance: MAbuseFull) => Promise<{ isOwned: boolean} >
+  transaction: Transaction
+}) {
+  const { base, reporterAccount, flaggedAccount, associateFun, transaction } = options
+  const auditLogger = auditLoggerFactory('abuse')
+
+  const abuseAttributes = Object.assign({}, base, { flaggedAccountId: flaggedAccount.id })
+  const abuseInstance: MAbuseFull = await AbuseModel.create(abuseAttributes, { transaction })
+
+  abuseInstance.ReporterAccount = reporterAccount
+  abuseInstance.FlaggedAccount = flaggedAccount
+
+  const { isOwned } = await associateFun(abuseInstance)
+
+  if (isOwned === false) {
+    await sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction)
+  }
+
+  const abuseJSON = abuseInstance.toFormattedJSON()
+  auditLogger.create(reporterAccount.Actor.getIdentifier(), new AbuseAuditView(abuseJSON))
+
+  Notifier.Instance.notifyOnNewAbuse({
+    abuse: abuseJSON,
+    abuseInstance,
+    reporter: reporterAccount.Actor.getIdentifier()
+  })
+
+  logger.info('Abuse report %d created.', abuseInstance.id)
+
+  return abuseJSON
 }
index 943a087d2e8315bb575b29803aa5b03403c3a813..c567e1c200f945c4e604385854b2e5a2488b599a 100644 (file)
@@ -8,23 +8,18 @@ import {
   MUserWithNotificationSetting,
   UserNotificationModelForApi
 } from '@server/types/models/user'
+import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
 import { MVideoImportVideo } from '@server/types/models/video/video-import'
+import { Abuse } from '@shared/models'
 import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
-import { VideoAbuse, VideoPrivacy, VideoState } from '../../shared/models/videos'
+import { VideoPrivacy, VideoState } from '../../shared/models/videos'
 import { logger } from '../helpers/logger'
 import { CONFIG } from '../initializers/config'
 import { AccountBlocklistModel } from '../models/account/account-blocklist'
 import { UserModel } from '../models/account/user'
 import { UserNotificationModel } from '../models/account/user-notification'
-import { MAccountServer, MActorFollowFull } from '../types/models'
-import {
-  MCommentOwnerVideo,
-  MVideoAbuseVideo,
-  MVideoAccountLight,
-  MVideoBlacklistLightVideo,
-  MVideoBlacklistVideo,
-  MVideoFullLight
-} from '../types/models/video'
+import { MAbuseFull, MAccountServer, MActorFollowFull } from '../types/models'
+import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video'
 import { isBlockedByServerOrAccount } from './blocklist'
 import { Emailer } from './emailer'
 import { PeerTubeSocket } from './peertube-socket'
@@ -78,9 +73,9 @@ class Notifier {
         .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
   }
 
-  notifyOnNewVideoAbuse (parameters: { videoAbuse: VideoAbuse, videoAbuseInstance: MVideoAbuseVideo, reporter: string }): void {
-    this.notifyModeratorsOfNewVideoAbuse(parameters)
-        .catch(err => logger.error('Cannot notify of new video abuse of video %s.', parameters.videoAbuseInstance.Video.url, { err }))
+  notifyOnNewAbuse (parameters: { abuse: Abuse, abuseInstance: MAbuseFull, reporter: string }): void {
+    this.notifyModeratorsOfNewAbuse(parameters)
+        .catch(err => logger.error('Cannot notify of new abuse %d.', parameters.abuseInstance.id, { err }))
   }
 
   notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
@@ -354,33 +349,39 @@ class Notifier {
     return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
   }
 
-  private async notifyModeratorsOfNewVideoAbuse (parameters: {
-    videoAbuse: VideoAbuse
-    videoAbuseInstance: MVideoAbuseVideo
+  private async notifyModeratorsOfNewAbuse (parameters: {
+    abuse: Abuse
+    abuseInstance: MAbuseFull
     reporter: string
   }) {
-    const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
+    const { abuse, abuseInstance } = parameters
+
+    const moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
     if (moderators.length === 0) return
 
-    logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, parameters.videoAbuseInstance.Video.url)
+    const url = abuseInstance.VideoAbuse?.Video?.url ||
+                abuseInstance.VideoCommentAbuse?.VideoComment?.url ||
+                abuseInstance.FlaggedAccount.Actor.url
+
+    logger.info('Notifying %s user/moderators of new abuse %s.', moderators.length, url)
 
     function settingGetter (user: MUserWithNotificationSetting) {
-      return user.NotificationSetting.videoAbuseAsModerator
+      return user.NotificationSetting.abuseAsModerator
     }
 
     async function notificationCreator (user: MUserWithNotificationSetting) {
-      const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({
-        type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
+      const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
+        type: UserNotificationType.NEW_ABUSE_FOR_MODERATORS,
         userId: user.id,
-        videoAbuseId: parameters.videoAbuse.id
+        abuseId: abuse.id
       })
-      notification.VideoAbuse = parameters.videoAbuseInstance
+      notification.Abuse = abuseInstance
 
       return notification
     }
 
     function emailSender (emails: string[]) {
-      return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, parameters)
+      return Emailer.Instance.addAbuseModeratorsNotification(emails, parameters)
     }
 
     return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })
index 43eef8ab17571b933a15cad8aa2b04de51a98d71..642549879ef03bf5f474a05d4afdb59b88d624bc 100644 (file)
@@ -133,7 +133,7 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
     newCommentOnMyVideo: UserNotificationSettingValue.WEB,
     myVideoImportFinished: UserNotificationSettingValue.WEB,
     myVideoPublished: UserNotificationSettingValue.WEB,
-    videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+    abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newUserRegistration: UserNotificationSettingValue.WEB,
diff --git a/server/middlewares/validators/abuse.ts b/server/middlewares/validators/abuse.ts
new file mode 100644 (file)
index 0000000..966d1f7
--- /dev/null
@@ -0,0 +1,277 @@
+import * as express from 'express'
+import { body, param, query } from 'express-validator'
+import {
+  isAbuseFilterValid,
+  isAbuseModerationCommentValid,
+  isAbusePredefinedReasonsValid,
+  isAbusePredefinedReasonValid,
+  isAbuseReasonValid,
+  isAbuseStateValid,
+  isAbuseTimestampCoherent,
+  isAbuseTimestampValid,
+  isAbuseVideoIsValid
+} from '@server/helpers/custom-validators/abuses'
+import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '@server/helpers/custom-validators/misc'
+import { doesCommentIdExist } from '@server/helpers/custom-validators/video-comments'
+import { logger } from '@server/helpers/logger'
+import { doesAbuseExist, doesAccountIdExist, doesVideoAbuseExist, doesVideoExist } from '@server/helpers/middlewares'
+import { AbuseCreate } from '@shared/models'
+import { areValidationErrors } from './utils'
+
+const abuseReportValidator = [
+  body('account.id')
+    .optional()
+    .custom(isIdValid)
+    .withMessage('Should have a valid accountId'),
+
+  body('video.id')
+    .optional()
+    .custom(isIdOrUUIDValid)
+    .withMessage('Should have a valid videoId'),
+  body('video.startAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isAbuseTimestampValid)
+    .withMessage('Should have valid starting time value'),
+  body('video.endAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isAbuseTimestampValid)
+    .withMessage('Should have valid ending time value')
+    .bail()
+    .custom(isAbuseTimestampCoherent)
+    .withMessage('Should have a startAt timestamp beginning before endAt'),
+
+  body('comment.id')
+    .optional()
+    .custom(isIdValid)
+    .withMessage('Should have a valid commentId'),
+
+  body('reason')
+    .custom(isAbuseReasonValid)
+    .withMessage('Should have a valid reason'),
+
+  body('predefinedReasons')
+    .optional()
+    .custom(isAbusePredefinedReasonsValid)
+    .withMessage('Should have a valid list of predefined reasons'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking abuseReport parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    const body: AbuseCreate = req.body
+
+    if (body.video?.id && !await doesVideoExist(body.video.id, res)) return
+    if (body.account?.id && !await doesAccountIdExist(body.account.id, res)) return
+    if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return
+
+    if (!body.video?.id && !body.account?.id && !body.comment?.id) {
+      res.status(400)
+        .json({ error: 'video id or account id or comment id is required.' })
+
+      return
+    }
+
+    return next()
+  }
+]
+
+const abuseGetValidator = [
+  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking abuseGetValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesAbuseExist(req.params.id, res)) return
+
+    return next()
+  }
+]
+
+const abuseUpdateValidator = [
+  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
+
+  body('state')
+    .optional()
+    .custom(isAbuseStateValid).withMessage('Should have a valid abuse state'),
+  body('moderationComment')
+    .optional()
+    .custom(isAbuseModerationCommentValid).withMessage('Should have a valid moderation comment'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking abuseUpdateValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesAbuseExist(req.params.id, res)) return
+
+    return next()
+  }
+]
+
+const abuseListValidator = [
+  query('id')
+    .optional()
+    .custom(isIdValid).withMessage('Should have a valid id'),
+  query('filter')
+    .optional()
+    .custom(isAbuseFilterValid)
+    .withMessage('Should have a valid filter'),
+  query('predefinedReason')
+    .optional()
+    .custom(isAbusePredefinedReasonValid)
+    .withMessage('Should have a valid predefinedReason'),
+  query('search')
+    .optional()
+    .custom(exists).withMessage('Should have a valid search'),
+  query('state')
+    .optional()
+    .custom(isAbuseStateValid).withMessage('Should have a valid abuse state'),
+  query('videoIs')
+    .optional()
+    .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
+  query('searchReporter')
+    .optional()
+    .custom(exists).withMessage('Should have a valid reporter search'),
+  query('searchReportee')
+    .optional()
+    .custom(exists).withMessage('Should have a valid reportee search'),
+  query('searchVideo')
+    .optional()
+    .custom(exists).withMessage('Should have a valid video search'),
+  query('searchVideoChannel')
+    .optional()
+    .custom(exists).withMessage('Should have a valid video channel search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking abuseListValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// FIXME: deprecated in 2.3. Remove these validators
+
+const videoAbuseReportValidator = [
+  param('videoId')
+    .custom(isIdOrUUIDValid)
+    .not()
+    .isEmpty()
+    .withMessage('Should have a valid videoId'),
+  body('reason')
+    .custom(isAbuseReasonValid)
+    .withMessage('Should have a valid reason'),
+  body('predefinedReasons')
+    .optional()
+    .custom(isAbusePredefinedReasonsValid)
+    .withMessage('Should have a valid list of predefined reasons'),
+  body('startAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isAbuseTimestampValid)
+    .withMessage('Should have valid starting time value'),
+  body('endAt')
+    .optional()
+    .customSanitizer(toIntOrNull)
+    .custom(isAbuseTimestampValid)
+    .withMessage('Should have valid ending time value'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoExist(req.params.videoId, res)) return
+
+    return next()
+  }
+]
+
+const videoAbuseGetValidator = [
+  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
+
+    return next()
+  }
+]
+
+const videoAbuseUpdateValidator = [
+  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
+  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
+  body('state')
+    .optional()
+    .custom(isAbuseStateValid).withMessage('Should have a valid video abuse state'),
+  body('moderationComment')
+    .optional()
+    .custom(isAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
+
+    return next()
+  }
+]
+
+const videoAbuseListValidator = [
+  query('id')
+    .optional()
+    .custom(isIdValid).withMessage('Should have a valid id'),
+  query('predefinedReason')
+    .optional()
+    .custom(isAbusePredefinedReasonValid)
+    .withMessage('Should have a valid predefinedReason'),
+  query('search')
+    .optional()
+    .custom(exists).withMessage('Should have a valid search'),
+  query('state')
+    .optional()
+    .custom(isAbuseStateValid).withMessage('Should have a valid video abuse state'),
+  query('videoIs')
+    .optional()
+    .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
+  query('searchReporter')
+    .optional()
+    .custom(exists).withMessage('Should have a valid reporter search'),
+  query('searchReportee')
+    .optional()
+    .custom(exists).withMessage('Should have a valid reportee search'),
+  query('searchVideo')
+    .optional()
+    .custom(exists).withMessage('Should have a valid video search'),
+  query('searchVideoChannel')
+    .optional()
+    .custom(exists).withMessage('Should have a valid video channel search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoAbuseListValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  abuseListValidator,
+  abuseReportValidator,
+  abuseGetValidator,
+  abuseUpdateValidator,
+  videoAbuseReportValidator,
+  videoAbuseGetValidator,
+  videoAbuseUpdateValidator,
+  videoAbuseListValidator
+}
index 65dd00335ed7d6762c9ba28131fdd7e790e93526..4086d77aa70a035d73a82e95cff96bdc9fe15553 100644 (file)
@@ -1,3 +1,4 @@
+export * from './abuse'
 export * from './account'
 export * from './blocklist'
 export * from './oembed'
index b76dab722d34d610430efa549f0c6333bb191ed5..29aba04367fe659b2ae87b5ff0baf7eb887a959b 100644 (file)
@@ -5,7 +5,7 @@ import { checkSort, createSortableColumns } from './utils'
 const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS)
 const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS)
 const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS)
-const SORTABLE_VIDEO_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_ABUSES)
+const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
 const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
 const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
 const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
@@ -28,7 +28,7 @@ const SORTABLE_VIDEO_REDUNDANCIES_COLUMNS = createSortableColumns(SORTABLE_COLUM
 const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
 const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
 const jobsSortValidator = checkSort(SORTABLE_JOBS_COLUMNS)
-const videoAbusesSortValidator = checkSort(SORTABLE_VIDEO_ABUSES_COLUMNS)
+const abusesSortValidator = checkSort(SORTABLE_ABUSES_COLUMNS)
 const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
 const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
 const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
@@ -52,7 +52,7 @@ const videoRedundanciesSortValidator = checkSort(SORTABLE_VIDEO_REDUNDANCIES_COL
 
 export {
   usersSortValidator,
-  videoAbusesSortValidator,
+  abusesSortValidator,
   videoChannelsSortValidator,
   videoImportsSortValidator,
   videosSearchSortValidator,
index fbfcb0a4ca0a0399d3235f74d7fc7ab2865a44b7..21a7be08de68e39ad1ac5d7a75e10ebf8c5d420b 100644 (file)
@@ -25,8 +25,8 @@ const updateNotificationSettingsValidator = [
     .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
   body('newCommentOnMyVideo')
     .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
-  body('videoAbuseAsModerator')
-    .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'),
+  body('abuseAsModerator')
+    .custom(isUserNotificationSettingValid).withMessage('Should have a valid abuse as moderator notification setting'),
   body('videoAutoBlacklistAsModerator')
     .custom(isUserNotificationSettingValid).withMessage('Should have a valid video auto blacklist notification setting'),
   body('blacklistOnMyVideo')
index a0d585b938f12102e76c94202b45559c8cbf14d0..1eabada0a2ce43fe7b0f25291dafbe67b2c7c669 100644 (file)
@@ -1,4 +1,3 @@
-export * from './video-abuses'
 export * from './video-blacklist'
 export * from './video-captions'
 export * 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 (file)
index 5bbd1e3..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-import * as express from 'express'
-import { body, param, query } from 'express-validator'
-import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
-import {
-  isAbuseVideoIsValid,
-  isVideoAbuseModerationCommentValid,
-  isVideoAbuseReasonValid,
-  isVideoAbuseStateValid,
-  isVideoAbusePredefinedReasonsValid,
-  isVideoAbusePredefinedReasonValid,
-  isVideoAbuseTimestampValid,
-  isVideoAbuseTimestampCoherent
-} from '../../../helpers/custom-validators/video-abuses'
-import { logger } from '../../../helpers/logger'
-import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares'
-import { areValidationErrors } from '../utils'
-
-const videoAbuseReportValidator = [
-  param('videoId')
-    .custom(isIdOrUUIDValid)
-    .not()
-    .isEmpty()
-    .withMessage('Should have a valid videoId'),
-  body('reason')
-    .custom(isVideoAbuseReasonValid)
-    .withMessage('Should have a valid reason'),
-  body('predefinedReasons')
-    .optional()
-    .custom(isVideoAbusePredefinedReasonsValid)
-    .withMessage('Should have a valid list of predefined reasons'),
-  body('startAt')
-    .optional()
-    .customSanitizer(toIntOrNull)
-    .custom(isVideoAbuseTimestampValid)
-    .withMessage('Should have valid starting time value'),
-  body('endAt')
-    .optional()
-    .customSanitizer(toIntOrNull)
-    .custom(isVideoAbuseTimestampValid)
-    .withMessage('Should have valid ending time value')
-    .bail()
-    .custom(isVideoAbuseTimestampCoherent)
-    .withMessage('Should have a startAt timestamp beginning before endAt'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
-
-    if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res)) return
-
-    return next()
-  }
-]
-
-const videoAbuseGetValidator = [
-  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
-  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
-
-    if (areValidationErrors(req, res)) return
-    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
-
-    return next()
-  }
-]
-
-const videoAbuseUpdateValidator = [
-  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
-  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
-  body('state')
-    .optional()
-    .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
-  body('moderationComment')
-    .optional()
-    .custom(isVideoAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'),
-
-  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
-
-    if (areValidationErrors(req, res)) return
-    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
-
-    return next()
-  }
-]
-
-const videoAbuseListValidator = [
-  query('id')
-    .optional()
-    .custom(isIdValid).withMessage('Should have a valid id'),
-  query('predefinedReason')
-    .optional()
-    .custom(isVideoAbusePredefinedReasonValid)
-    .withMessage('Should have a valid predefinedReason'),
-  query('search')
-    .optional()
-    .custom(exists).withMessage('Should have a valid search'),
-  query('state')
-    .optional()
-    .custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
-  query('videoIs')
-    .optional()
-    .custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
-  query('searchReporter')
-    .optional()
-    .custom(exists).withMessage('Should have a valid reporter search'),
-  query('searchReportee')
-    .optional()
-    .custom(exists).withMessage('Should have a valid reportee search'),
-  query('searchVideo')
-    .optional()
-    .custom(exists).withMessage('Should have a valid video search'),
-  query('searchVideoChannel')
-    .optional()
-    .custom(exists).withMessage('Should have a valid video channel search'),
-
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    logger.debug('Checking videoAbuseListValidator parameters', { parameters: req.body })
-
-    if (areValidationErrors(req, res)) return
-
-    return next()
-  }
-]
-
-// ---------------------------------------------------------------------------
-
-export {
-  videoAbuseListValidator,
-  videoAbuseReportValidator,
-  videoAbuseGetValidator,
-  videoAbuseUpdateValidator
-}
index ef019fcf915f126f622c4c3df08d7dec00fe21c5..77f5c6ff3c5dd5bf41c51951308dc8e8fefda410 100644 (file)
@@ -3,13 +3,16 @@ import { body, param } from 'express-validator'
 import { MUserAccountUrl } from '@server/types/models'
 import { UserRight } from '../../../../shared'
 import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc'
-import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments'
+import {
+  doesVideoCommentExist,
+  doesVideoCommentThreadExist,
+  isValidVideoCommentText
+} from '../../../helpers/custom-validators/video-comments'
 import { logger } from '../../../helpers/logger'
 import { doesVideoExist } from '../../../helpers/middlewares'
 import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation'
 import { Hooks } from '../../../lib/plugins/hooks'
-import { VideoCommentModel } from '../../../models/video/video-comment'
-import { MCommentOwnerVideoReply, MVideo, MVideoFullLight, MVideoId } from '../../../types/models/video'
+import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video'
 import { areValidationErrors } from '../utils'
 
 const listVideoCommentThreadsValidator = [
@@ -120,67 +123,10 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
-  const id = parseInt(idArg + '', 10)
-  const videoComment = await VideoCommentModel.loadById(id)
-
-  if (!videoComment) {
-    res.status(404)
-      .json({ error: 'Video comment thread not found' })
-      .end()
-
-    return false
-  }
-
-  if (videoComment.videoId !== video.id) {
-    res.status(400)
-      .json({ error: 'Video comment is not associated to this video.' })
-      .end()
-
-    return false
-  }
-
-  if (videoComment.inReplyToCommentId !== null) {
-    res.status(400)
-      .json({ error: 'Video comment is not a thread.' })
-      .end()
-
-    return false
-  }
-
-  res.locals.videoCommentThread = videoComment
-  return true
-}
-
-async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
-  const id = parseInt(idArg + '', 10)
-  const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
-
-  if (!videoComment) {
-    res.status(404)
-      .json({ error: 'Video comment thread not found' })
-      .end()
-
-    return false
-  }
-
-  if (videoComment.videoId !== video.id) {
-    res.status(400)
-      .json({ error: 'Video comment is not associated to this video.' })
-      .end()
-
-    return false
-  }
-
-  res.locals.videoCommentFull = videoComment
-  return true
-}
-
 function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
   if (video.commentsEnabled !== true) {
     res.status(409)
       .json({ error: 'Video comments are disabled for this video.' })
-      .end()
 
     return false
   }
@@ -192,7 +138,7 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC
   if (videoComment.isDeleted()) {
     res.status(409)
       .json({ error: 'This comment is already deleted' })
-      .end()
+
     return false
   }
 
@@ -240,7 +186,7 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon
   if (!acceptedResult || acceptedResult.accepted !== true) {
     logger.info('Refused local comment.', { acceptedResult, acceptParameters })
     res.status(403)
-              .json({ error: acceptedResult.errorMessage || 'Refused local comment' })
+       .json({ error: acceptedResult.errorMessage || 'Refused local comment' })
 
     return false
   }
diff --git a/server/models/abuse/abuse-query-builder.ts b/server/models/abuse/abuse-query-builder.ts
new file mode 100644 (file)
index 0000000..5fddcf3
--- /dev/null
@@ -0,0 +1,154 @@
+
+import { exists } from '@server/helpers/custom-validators/misc'
+import { AbuseFilter, AbuseState, AbuseVideoIs } from '@shared/models'
+import { buildBlockedAccountSQL, buildDirectionAndField } from '../utils'
+
+export type BuildAbusesQueryOptions = {
+  start: number
+  count: number
+  sort: string
+
+  // search
+  search?: string
+  searchReporter?: string
+  searchReportee?: string
+
+  // video releated
+  searchVideo?: string
+  searchVideoChannel?: string
+  videoIs?: AbuseVideoIs
+
+  // filters
+  id?: number
+  predefinedReasonId?: number
+  filter?: AbuseFilter
+
+  state?: AbuseState
+
+  // accountIds
+  serverAccountId: number
+  userAccountId: number
+}
+
+function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' | 'id') {
+  const whereAnd: string[] = []
+  const replacements: any = {}
+
+  const joins = [
+    'LEFT JOIN "videoAbuse" ON "videoAbuse"."abuseId" = "abuse"."id"',
+    'LEFT JOIN "video" ON "videoAbuse"."videoId" = "video"."id"',
+    'LEFT JOIN "videoBlacklist" ON "videoBlacklist"."videoId" = "video"."id"',
+    'LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"',
+    'LEFT JOIN "account" "reporterAccount" ON "reporterAccount"."id" = "abuse"."reporterAccountId"',
+    'LEFT JOIN "account" "flaggedAccount" ON "flaggedAccount"."id" = "abuse"."reporterAccountId"',
+    'LEFT JOIN "commentAbuse" ON "commentAbuse"."abuseId" = "abuse"."id"',
+    'LEFT JOIN "videoComment" ON "commentAbuse"."videoCommentId" = "videoComment"."id"'
+  ]
+
+  whereAnd.push('"abuse"."reporterAccountId" NOT IN (' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
+
+  if (options.search) {
+    const searchWhereOr = [
+      '"video"."name" ILIKE :search',
+      '"videoChannel"."name" ILIKE :search',
+      `"videoAbuse"."deletedVideo"->>'name' ILIKE :search`,
+      `"videoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE :search`,
+      '"reporterAccount"."name" ILIKE :search',
+      '"flaggedAccount"."name" ILIKE :search'
+    ]
+
+    replacements.search = `%${options.search}%`
+    whereAnd.push('(' + searchWhereOr.join(' OR ') + ')')
+  }
+
+  if (options.searchVideo) {
+    whereAnd.push('"video"."name" ILIKE :searchVideo')
+    replacements.searchVideo = `%${options.searchVideo}%`
+  }
+
+  if (options.searchVideoChannel) {
+    whereAnd.push('"videoChannel"."name" ILIKE :searchVideoChannel')
+    replacements.searchVideoChannel = `%${options.searchVideoChannel}%`
+  }
+
+  if (options.id) {
+    whereAnd.push('"abuse"."id" = :id')
+    replacements.id = options.id
+  }
+
+  if (options.state) {
+    whereAnd.push('"abuse"."state" = :state')
+    replacements.state = options.state
+  }
+
+  if (options.videoIs === 'deleted') {
+    whereAnd.push('"videoAbuse"."deletedVideo" IS NOT NULL')
+  } else if (options.videoIs === 'blacklisted') {
+    whereAnd.push('"videoBlacklist"."id" IS NOT NULL')
+  }
+
+  if (options.predefinedReasonId) {
+    whereAnd.push(':predefinedReasonId = ANY("abuse"."predefinedReasons")')
+    replacements.predefinedReasonId = options.predefinedReasonId
+  }
+
+  if (options.filter === 'video') {
+    whereAnd.push('"videoAbuse"."id" IS NOT NULL')
+  } else if (options.filter === 'comment') {
+    whereAnd.push('"commentAbuse"."id" IS NOT NULL')
+  } else if (options.filter === 'account') {
+    whereAnd.push('"videoAbuse"."id" IS NULL AND "commentAbuse"."id" IS NULL')
+  }
+
+  if (options.searchReporter) {
+    whereAnd.push('"reporterAccount"."name" ILIKE :searchReporter')
+    replacements.searchReporter = `%${options.searchReporter}%`
+  }
+
+  if (options.searchReportee) {
+    whereAnd.push('"flaggedAccount"."name" ILIKE :searchReportee')
+    replacements.searchReportee = `%${options.searchReportee}%`
+  }
+
+  const prefix = type === 'count'
+    ? 'SELECT COUNT("abuse"."id") AS "total"'
+    : 'SELECT "abuse"."id" '
+
+  let suffix = ''
+  if (type !== 'count') {
+
+    if (options.sort) {
+      const order = buildAbuseOrder(options.sort)
+      suffix += `${order} `
+    }
+
+    if (exists(options.count)) {
+      const count = parseInt(options.count + '', 10)
+      suffix += `LIMIT ${count} `
+    }
+
+    if (exists(options.start)) {
+      const start = parseInt(options.start + '', 10)
+      suffix += `OFFSET ${start} `
+    }
+  }
+
+  const where = whereAnd.length !== 0
+    ? `WHERE ${whereAnd.join(' AND ')}`
+    : ''
+
+  return {
+    query: `${prefix} FROM "abuse" ${joins.join(' ')} ${where} ${suffix}`,
+    replacements
+  }
+}
+
+function buildAbuseOrder (value: string) {
+  const { direction, field } = buildDirectionAndField(value)
+
+  return `ORDER BY "abuse"."${field}" ${direction}`
+}
+
+export {
+  buildAbuseListQuery
+}
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
new file mode 100644 (file)
index 0000000..bd96cf7
--- /dev/null
@@ -0,0 +1,515 @@
+import * as Bluebird from 'bluebird'
+import { invert } from 'lodash'
+import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'
+import {
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  ForeignKey,
+  HasOne,
+  Is,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
+import {
+  Abuse,
+  AbuseFilter,
+  AbuseObject,
+  AbusePredefinedReasons,
+  abusePredefinedReasonsMap,
+  AbusePredefinedReasonsString,
+  AbuseState,
+  AbuseVideoIs,
+  VideoAbuse,
+  VideoCommentAbuse
+} from '@shared/models'
+import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
+import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models'
+import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
+import { getSort, throwIfNotValid } from '../utils'
+import { ThumbnailModel } from '../video/thumbnail'
+import { VideoModel } from '../video/video'
+import { VideoBlacklistModel } from '../video/video-blacklist'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
+import { VideoCommentModel } from '../video/video-comment'
+import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
+import { VideoAbuseModel } from './video-abuse'
+import { VideoCommentAbuseModel } from './video-comment-abuse'
+
+export enum ScopeNames {
+  FOR_API = 'FOR_API'
+}
+
+@Scopes(() => ({
+  [ScopeNames.FOR_API]: () => {
+    return {
+      attributes: {
+        include: [
+          [
+            // we don't care about this count for deleted videos, so there are not included
+            literal(
+              '(' +
+                'SELECT count(*) ' +
+                'FROM "videoAbuse" ' +
+                'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
+              ')'
+            ),
+            'countReportsForVideo'
+          ],
+          [
+            // we don't care about this count for deleted videos, so there are not included
+            literal(
+              '(' +
+                'SELECT t.nth ' +
+                'FROM ( ' +
+                  'SELECT id, ' +
+                         'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
+                  'FROM "videoAbuse" ' +
+                ') t ' +
+                'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
+              ')'
+            ),
+            'nthReportForVideo'
+          ],
+          [
+            literal(
+              '(' +
+                'SELECT count("abuse"."id") ' +
+                'FROM "abuse" ' +
+                'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
+              ')'
+            ),
+            'countReportsForReporter'
+          ],
+          [
+            literal(
+              '(' +
+                'SELECT count("abuse"."id") ' +
+                'FROM "abuse" ' +
+                'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
+              ')'
+            ),
+            'countReportsForReportee'
+          ]
+        ]
+      },
+      include: [
+        {
+          model: AccountModel.scope({
+            method: [
+              AccountScopeNames.SUMMARY,
+              { actorRequired: false } as AccountSummaryOptions
+            ]
+          }),
+          as: 'ReporterAccount'
+        },
+        {
+          model: AccountModel.scope({
+            method: [
+              AccountScopeNames.SUMMARY,
+              { actorRequired: false } as AccountSummaryOptions
+            ]
+          }),
+          as: 'FlaggedAccount'
+        },
+        {
+          model: VideoCommentAbuseModel.unscoped(),
+          include: [
+            {
+              model: VideoCommentModel.unscoped(),
+              include: [
+                {
+                  model: VideoModel.unscoped(),
+                  attributes: [ 'name', 'id', 'uuid' ]
+                }
+              ]
+            }
+          ]
+        },
+        {
+          model: VideoAbuseModel.unscoped(),
+          include: [
+            {
+              attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
+              model: VideoModel.unscoped(),
+              include: [
+                {
+                  attributes: [ 'filename', 'fileUrl', 'type' ],
+                  model: ThumbnailModel
+                },
+                {
+                  model: VideoChannelModel.scope({
+                    method: [
+                      VideoChannelScopeNames.SUMMARY,
+                      { withAccount: false, actorRequired: false } as ChannelSummaryOptions
+                    ]
+                  }),
+                  required: false
+                },
+                {
+                  attributes: [ 'id', 'reason', 'unfederated' ],
+                  required: false,
+                  model: VideoBlacklistModel
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  }
+}))
+@Table({
+  tableName: 'abuse',
+  indexes: [
+    {
+      fields: [ 'reporterAccountId' ]
+    },
+    {
+      fields: [ 'flaggedAccountId' ]
+    }
+  ]
+})
+export class AbuseModel extends Model<AbuseModel> {
+
+  @AllowNull(false)
+  @Default(null)
+  @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
+  reason: string
+
+  @AllowNull(false)
+  @Default(null)
+  @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
+  @Column
+  state: AbuseState
+
+  @AllowNull(true)
+  @Default(null)
+  @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
+  moderationComment: string
+
+  @AllowNull(true)
+  @Default(null)
+  @Column(DataType.ARRAY(DataType.INTEGER))
+  predefinedReasons: AbusePredefinedReasons[]
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  reporterAccountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      name: 'reporterAccountId',
+      allowNull: true
+    },
+    as: 'ReporterAccount',
+    onDelete: 'set null'
+  })
+  ReporterAccount: AccountModel
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  flaggedAccountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      name: 'flaggedAccountId',
+      allowNull: true
+    },
+    as: 'FlaggedAccount',
+    onDelete: 'set null'
+  })
+  FlaggedAccount: AccountModel
+
+  @HasOne(() => VideoCommentAbuseModel, {
+    foreignKey: {
+      name: 'abuseId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoCommentAbuse: VideoCommentAbuseModel
+
+  @HasOne(() => VideoAbuseModel, {
+    foreignKey: {
+      name: 'abuseId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoAbuse: VideoAbuseModel
+
+  // FIXME: deprecated in 2.3. Remove these validators
+  static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
+    const videoWhere: WhereOptions = {}
+
+    if (videoId) videoWhere.videoId = videoId
+    if (uuid) videoWhere.deletedVideo = { uuid }
+
+    const query = {
+      include: [
+        {
+          model: VideoAbuseModel,
+          required: true,
+          where: videoWhere
+        }
+      ],
+      where: {
+        id
+      }
+    }
+    return AbuseModel.findOne(query)
+  }
+
+  static loadById (id: number): Bluebird<MAbuse> {
+    const query = {
+      where: {
+        id
+      }
+    }
+
+    return AbuseModel.findOne(query)
+  }
+
+  static async listForApi (parameters: {
+    start: number
+    count: number
+    sort: string
+
+    filter?: AbuseFilter
+
+    serverAccountId: number
+    user?: MUserAccountId
+
+    id?: number
+    predefinedReason?: AbusePredefinedReasonsString
+    state?: AbuseState
+    videoIs?: AbuseVideoIs
+
+    search?: string
+    searchReporter?: string
+    searchReportee?: string
+    searchVideo?: string
+    searchVideoChannel?: string
+  }) {
+    const {
+      start,
+      count,
+      sort,
+      search,
+      user,
+      serverAccountId,
+      state,
+      videoIs,
+      predefinedReason,
+      searchReportee,
+      searchVideo,
+      filter,
+      searchVideoChannel,
+      searchReporter,
+      id
+    } = parameters
+
+    const userAccountId = user ? user.Account.id : undefined
+    const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
+
+    const queryOptions: BuildAbusesQueryOptions = {
+      start,
+      count,
+      sort,
+      id,
+      filter,
+      predefinedReasonId,
+      search,
+      state,
+      videoIs,
+      searchReportee,
+      searchVideo,
+      searchVideoChannel,
+      searchReporter,
+      serverAccountId,
+      userAccountId
+    }
+
+    const [ total, data ] = await Promise.all([
+      AbuseModel.internalCountForApi(queryOptions),
+      AbuseModel.internalListForApi(queryOptions)
+    ])
+
+    return { total, data }
+  }
+
+  toFormattedJSON (this: MAbuseFormattable): Abuse {
+    const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+
+    const countReportsForVideo = this.get('countReportsForVideo') as number
+    const nthReportForVideo = this.get('nthReportForVideo') as number
+
+    const countReportsForReporter = this.get('countReportsForReporter') as number
+    const countReportsForReportee = this.get('countReportsForReportee') as number
+
+    let video: VideoAbuse = null
+    let comment: VideoCommentAbuse = null
+
+    if (this.VideoAbuse) {
+      const abuseModel = this.VideoAbuse
+      const entity = abuseModel.Video || abuseModel.deletedVideo
+
+      video = {
+        id: entity.id,
+        uuid: entity.uuid,
+        name: entity.name,
+        nsfw: entity.nsfw,
+
+        startAt: abuseModel.startAt,
+        endAt: abuseModel.endAt,
+
+        deleted: !abuseModel.Video,
+        blacklisted: abuseModel.Video?.isBlacklisted() || false,
+        thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
+
+        channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel,
+
+        countReports: countReportsForVideo,
+        nthReport: nthReportForVideo
+      }
+    }
+
+    if (this.VideoCommentAbuse) {
+      const abuseModel = this.VideoCommentAbuse
+      const entity = abuseModel.VideoComment
+
+      comment = {
+        id: entity.id,
+        threadId: entity.getThreadId(),
+
+        text: entity.text ?? '',
+
+        deleted: entity.isDeleted(),
+
+        video: {
+          id: entity.Video.id,
+          name: entity.Video.name,
+          uuid: entity.Video.uuid
+        }
+      }
+    }
+
+    return {
+      id: this.id,
+      reason: this.reason,
+      predefinedReasons,
+
+      reporterAccount: this.ReporterAccount
+        ? this.ReporterAccount.toFormattedJSON()
+        : null,
+
+      flaggedAccount: this.FlaggedAccount
+        ? this.FlaggedAccount.toFormattedJSON()
+        : null,
+
+      state: {
+        id: this.state,
+        label: AbuseModel.getStateLabel(this.state)
+      },
+
+      moderationComment: this.moderationComment,
+
+      video,
+      comment,
+
+      createdAt: this.createdAt,
+      updatedAt: this.updatedAt,
+
+      countReportsForReporter: (countReportsForReporter || 0),
+      countReportsForReportee: (countReportsForReportee || 0),
+
+      // FIXME: deprecated in 2.3, remove this
+      startAt: null,
+      endAt: null,
+      count: countReportsForVideo || 0,
+      nth: nthReportForVideo || 0
+    }
+  }
+
+  toActivityPubObject (this: MAbuseAP): AbuseObject {
+    const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+
+    const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
+
+    const startAt = this.VideoAbuse?.startAt
+    const endAt = this.VideoAbuse?.endAt
+
+    return {
+      type: 'Flag' as 'Flag',
+      content: this.reason,
+      object,
+      tag: predefinedReasons.map(r => ({
+        type: 'Hashtag' as 'Hashtag',
+        name: r
+      })),
+      startAt,
+      endAt
+    }
+  }
+
+  private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
+    const { query, replacements } = buildAbuseListQuery(parameters, 'count')
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
+
+    const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
+    if (total === null) return 0
+
+    return parseInt(total, 10)
+  }
+
+  private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
+    const { query, replacements } = buildAbuseListQuery(parameters, 'id')
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
+
+    const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
+    const ids = rows.map(r => r.id)
+
+    if (ids.length === 0) return []
+
+    return AbuseModel.scope(ScopeNames.FOR_API)
+                     .findAll({
+                       order: getSort(parameters.sort),
+                       where: {
+                         id: {
+                           [Op.in]: ids
+                         }
+                       }
+                     })
+  }
+
+  private static getStateLabel (id: number) {
+    return ABUSE_STATES[id] || 'Unknown'
+  }
+
+  private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
+    return (predefinedReasons || [])
+      .filter(r => r in AbusePredefinedReasons)
+      .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString)
+  }
+}
diff --git a/server/models/abuse/video-abuse.ts b/server/models/abuse/video-abuse.ts
new file mode 100644 (file)
index 0000000..d92bcf1
--- /dev/null
@@ -0,0 +1,63 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { VideoDetails } from '@shared/models'
+import { VideoModel } from '../video/video'
+import { AbuseModel } from './abuse'
+
+@Table({
+  tableName: 'videoAbuse',
+  indexes: [
+    {
+      fields: [ 'abuseId' ]
+    },
+    {
+      fields: [ 'videoId' ]
+    }
+  ]
+})
+export class VideoAbuseModel extends Model<VideoAbuseModel> {
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  startAt: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  endAt: number
+
+  @AllowNull(true)
+  @Default(null)
+  @Column(DataType.JSONB)
+  deletedVideo: VideoDetails
+
+  @ForeignKey(() => AbuseModel)
+  @Column
+  abuseId: number
+
+  @BelongsTo(() => AbuseModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  Abuse: AbuseModel
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'set null'
+  })
+  Video: VideoModel
+}
diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts
new file mode 100644 (file)
index 0000000..8b34009
--- /dev/null
@@ -0,0 +1,47 @@
+import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { VideoCommentModel } from '../video/video-comment'
+import { AbuseModel } from './abuse'
+
+@Table({
+  tableName: 'commentAbuse',
+  indexes: [
+    {
+      fields: [ 'abuseId' ]
+    },
+    {
+      fields: [ 'videoCommentId' ]
+    }
+  ]
+})
+export class VideoCommentAbuseModel extends Model<VideoCommentAbuseModel> {
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => AbuseModel)
+  @Column
+  abuseId: number
+
+  @BelongsTo(() => AbuseModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  Abuse: AbuseModel
+
+  @ForeignKey(() => VideoCommentModel)
+  @Column
+  videoCommentId: number
+
+  @BelongsTo(() => VideoCommentModel, {
+    foreignKey: {
+      allowNull: true
+    },
+    onDelete: 'set null'
+  })
+  VideoComment: VideoCommentModel
+}
index cf8872fd5f9153976fb7beca3fd79557d26ce121..577b7dc192fb88175a16c7b1949d17908d84673c 100644 (file)
@@ -1,12 +1,12 @@
-import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
-import { AccountModel } from './account'
-import { getSort, searchAttribute } from '../utils'
-import { AccountBlock } from '../../../shared/models/blocklist'
-import { Op } from 'sequelize'
 import * as Bluebird from 'bluebird'
+import { Op } from 'sequelize'
+import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
 import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
+import { AccountBlock } from '../../../shared/models'
 import { ActorModel } from '../activitypub/actor'
 import { ServerModel } from '../server/server'
+import { getSort, searchAttribute } from '../utils'
+import { AccountModel } from './account'
 
 enum ScopeNames {
   WITH_ACCOUNTS = 'WITH_ACCOUNTS'
index 4395d179ae471845bb08d66be5794e0de50551d9..f97519b1403eac1c5d42280aa40ea5e8bf9e6a52 100644 (file)
@@ -42,6 +42,7 @@ export enum ScopeNames {
 }
 
 export type SummaryOptions = {
+  actorRequired?: boolean // Default: true
   whereActor?: WhereOptions
   withAccountBlockerIds?: number[]
 }
@@ -65,12 +66,12 @@ export type SummaryOptions = {
     }
 
     const query: FindOptions = {
-      attributes: [ 'id', 'name' ],
+      attributes: [ 'id', 'name', 'actorId' ],
       include: [
         {
           attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
           model: ActorModel.unscoped(),
-          required: true,
+          required: options.actorRequired ?? true,
           where: whereActor,
           include: [
             serverInclude,
@@ -388,6 +389,10 @@ export class AccountModel extends Model<AccountModel> {
       .findAll(query)
   }
 
+  getClientUrl () {
+    return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
+  }
+
   toFormattedJSON (this: MAccountFormattable): Account {
     const actor = this.Actor.toFormattedJSON()
     const account = {
index b69b4726575ebf9447e43468e3531653a328551c..d8f3f13da1219116598d8bc1e67961fad026c814 100644 (file)
@@ -51,11 +51,11 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
   @AllowNull(false)
   @Default(null)
   @Is(
-    'UserNotificationSettingVideoAbuseAsModerator',
-    value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
+    'UserNotificationSettingAbuseAsModerator',
+    value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseAsModerator')
   )
   @Column
-  videoAbuseAsModerator: UserNotificationSettingValue
+  abuseAsModerator: UserNotificationSettingValue
 
   @AllowNull(false)
   @Default(null)
@@ -166,7 +166,7 @@ export class UserNotificationSettingModel extends Model<UserNotificationSettingM
     return {
       newCommentOnMyVideo: this.newCommentOnMyVideo,
       newVideoFromSubscription: this.newVideoFromSubscription,
-      videoAbuseAsModerator: this.videoAbuseAsModerator,
+      abuseAsModerator: this.abuseAsModerator,
       videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
       blacklistOnMyVideo: this.blacklistOnMyVideo,
       myVideoPublished: this.myVideoPublished,
index 30985bb0f19cb318f485578353bc250c8eb15a8b..2945bf70953656d3926caac95d45d91fb3c73812 100644 (file)
@@ -1,22 +1,24 @@
+import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
 import { UserNotification, UserNotificationType } from '../../../shared'
-import { getSort, throwIfNotValid } from '../utils'
 import { isBooleanValid } from '../../helpers/custom-validators/misc'
 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
-import { UserModel } from './user'
-import { VideoModel } from '../video/video'
-import { VideoCommentModel } from '../video/video-comment'
-import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
-import { VideoChannelModel } from '../video/video-channel'
-import { AccountModel } from './account'
-import { VideoAbuseModel } from '../video/video-abuse'
-import { VideoBlacklistModel } from '../video/video-blacklist'
-import { VideoImportModel } from '../video/video-import'
+import { AbuseModel } from '../abuse/abuse'
+import { VideoAbuseModel } from '../abuse/video-abuse'
+import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
 import { ActorModel } from '../activitypub/actor'
 import { ActorFollowModel } from '../activitypub/actor-follow'
 import { AvatarModel } from '../avatar/avatar'
 import { ServerModel } from '../server/server'
-import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
+import { getSort, throwIfNotValid } from '../utils'
+import { VideoModel } from '../video/video'
+import { VideoBlacklistModel } from '../video/video-blacklist'
+import { VideoChannelModel } from '../video/video-channel'
+import { VideoCommentModel } from '../video/video-comment'
+import { VideoImportModel } from '../video/video-import'
+import { AccountModel } from './account'
+import { UserModel } from './user'
 
 enum ScopeNames {
   WITH_ALL = 'WITH_ALL'
@@ -87,9 +89,41 @@ function buildAccountInclude (required: boolean, withActor = false) {
 
       {
         attributes: [ 'id' ],
-        model: VideoAbuseModel.unscoped(),
+        model: AbuseModel.unscoped(),
         required: false,
-        include: [ buildVideoInclude(true) ]
+        include: [
+          {
+            attributes: [ 'id' ],
+            model: VideoAbuseModel.unscoped(),
+            required: false,
+            include: [ buildVideoInclude(true) ]
+          },
+          {
+            attributes: [ 'id' ],
+            model: VideoCommentAbuseModel.unscoped(),
+            required: false,
+            include: [
+              {
+                attributes: [ 'id', 'originCommentId' ],
+                model: VideoCommentModel,
+                required: true,
+                include: [
+                  {
+                    attributes: [ 'id', 'name', 'uuid' ],
+                    model: VideoModel.unscoped(),
+                    required: true
+                  }
+                ]
+              }
+            ]
+          },
+          {
+            model: AccountModel,
+            as: 'FlaggedAccount',
+            required: true,
+            include: [ buildActorWithAvatarInclude() ]
+          }
+        ]
       },
 
       {
@@ -179,9 +213,9 @@ function buildAccountInclude (required: boolean, withActor = false) {
       }
     },
     {
-      fields: [ 'videoAbuseId' ],
+      fields: [ 'abuseId' ],
       where: {
-        videoAbuseId: {
+        abuseId: {
           [Op.ne]: null
         }
       }
@@ -276,17 +310,17 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
   })
   Comment: VideoCommentModel
 
-  @ForeignKey(() => VideoAbuseModel)
+  @ForeignKey(() => AbuseModel)
   @Column
-  videoAbuseId: number
+  abuseId: number
 
-  @BelongsTo(() => VideoAbuseModel, {
+  @BelongsTo(() => AbuseModel, {
     foreignKey: {
       allowNull: true
     },
     onDelete: 'cascade'
   })
-  VideoAbuse: VideoAbuseModel
+  Abuse: AbuseModel
 
   @ForeignKey(() => VideoBlacklistModel)
   @Column
@@ -397,10 +431,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
       video: this.formatVideo(this.Comment.Video)
     } : undefined
 
-    const videoAbuse = this.VideoAbuse ? {
-      id: this.VideoAbuse.id,
-      video: this.formatVideo(this.VideoAbuse.Video)
-    } : undefined
+    const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
 
     const videoBlacklist = this.VideoBlacklist ? {
       id: this.VideoBlacklist.id,
@@ -439,7 +470,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
       video,
       videoImport,
       comment,
-      videoAbuse,
+      abuse,
       videoBlacklist,
       account,
       actorFollow,
@@ -456,6 +487,29 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
     }
   }
 
+  formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
+    const commentAbuse = abuse.VideoCommentAbuse?.VideoComment ? {
+      threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
+
+      video: {
+        id: abuse.VideoCommentAbuse.VideoComment.Video.id,
+        name: abuse.VideoCommentAbuse.VideoComment.Video.name,
+        uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
+      }
+    } : undefined
+
+    const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
+
+    const accountAbuse = (!commentAbuse && !videoAbuse) ? this.formatActor(abuse.FlaggedAccount) : undefined
+
+    return {
+      id: abuse.id,
+      video: videoAbuse,
+      comment: commentAbuse,
+      account: accountAbuse
+    }
+  }
+
   formatActor (
     this: UserNotificationModelForApi,
     accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
index de193131a27815d8ee31168528beae93eeaa7575..5f45f8e7cd02376fc737b5f78c7ab2d3ecb808f4 100644 (file)
@@ -19,7 +19,7 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, VideoAbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared'
+import { hasUserRight, MyUser, USER_ROLE_LABELS, UserRight, AbuseState, VideoPlaylistType, VideoPrivacy } from '../../../shared'
 import { User, UserRole } from '../../../shared/models/users'
 import {
   isNoInstanceConfigWarningModal,
@@ -168,28 +168,26 @@ enum ScopeNames {
             '(' +
               `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
               'FROM (' +
-                'SELECT COUNT("videoAbuse"."id") AS "abuses", ' +
-                       `COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
-                'FROM "videoAbuse" ' +
-                'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
+                'SELECT COUNT("abuse"."id") AS "abuses", ' +
+                       `COUNT("abuse"."id") FILTER (WHERE "abuse"."state" = ${AbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
+                'FROM "abuse" ' +
+                'INNER JOIN "account" ON "account"."id" = "abuse"."flaggedAccountId" ' +
                 'WHERE "account"."userId" = "UserModel"."id"' +
               ') t' +
             ')'
           ),
-          'videoAbusesCount'
+          'abusesCount'
         ],
         [
           literal(
             '(' +
-              'SELECT COUNT("videoAbuse"."id") ' +
-              'FROM "videoAbuse" ' +
-              'INNER JOIN "account" ON "account"."id" = "videoAbuse"."reporterAccountId" ' +
+              'SELECT COUNT("abuse"."id") ' +
+              'FROM "abuse" ' +
+              'INNER JOIN "account" ON "account"."id" = "abuse"."reporterAccountId" ' +
               'WHERE "account"."userId" = "UserModel"."id"' +
             ')'
           ),
-          'videoAbusesCreatedCount'
+          'abusesCreatedCount'
         ],
         [
           literal(
@@ -780,8 +778,8 @@ export class UserModel extends Model<UserModel> {
     const videoQuotaUsed = this.get('videoQuotaUsed')
     const videoQuotaUsedDaily = this.get('videoQuotaUsedDaily')
     const videosCount = this.get('videosCount')
-    const [ videoAbusesCount, videoAbusesAcceptedCount ] = (this.get('videoAbusesCount') as string || ':').split(':')
-    const videoAbusesCreatedCount = this.get('videoAbusesCreatedCount')
+    const [ abusesCount, abusesAcceptedCount ] = (this.get('abusesCount') as string || ':').split(':')
+    const abusesCreatedCount = this.get('abusesCreatedCount')
     const videoCommentsCount = this.get('videoCommentsCount')
 
     const json: User = {
@@ -815,14 +813,14 @@ export class UserModel extends Model<UserModel> {
       videosCount: videosCount !== undefined
         ? parseInt(videosCount + '', 10)
         : undefined,
-      videoAbusesCount: videoAbusesCount
-        ? parseInt(videoAbusesCount, 10)
+      abusesCount: abusesCount
+        ? parseInt(abusesCount, 10)
         : undefined,
-      videoAbusesAcceptedCount: videoAbusesAcceptedCount
-        ? parseInt(videoAbusesAcceptedCount, 10)
+      abusesAcceptedCount: abusesAcceptedCount
+        ? parseInt(abusesAcceptedCount, 10)
         : undefined,
-      videoAbusesCreatedCount: videoAbusesCreatedCount !== undefined
-        ? parseInt(videoAbusesCreatedCount + '', 10)
+      abusesCreatedCount: abusesCreatedCount !== undefined
+        ? parseInt(abusesCreatedCount + '', 10)
         : undefined,
       videoCommentsCount: videoCommentsCount !== undefined
         ? parseInt(videoCommentsCount + '', 10)
index 30f0525e54a4d941f21b47a29f74187b0767a513..68cd72ee791c1dd3e6b575c3994569779d12589d 100644 (file)
@@ -1,11 +1,11 @@
+import * as Bluebird from 'bluebird'
+import { Op } from 'sequelize'
 import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
+import { ServerBlock } from '@shared/models'
 import { AccountModel } from '../account/account'
-import { ServerModel } from './server'
-import { ServerBlock } from '../../../shared/models/blocklist'
 import { getSort, searchAttribute } from '../utils'
-import * as Bluebird from 'bluebird'
-import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
-import { Op } from 'sequelize'
+import { ServerModel } from './server'
 
 enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
deleted file mode 100644 (file)
index 1319332..0000000
+++ /dev/null
@@ -1,479 +0,0 @@
-import * as Bluebird from 'bluebird'
-import { literal, Op } from 'sequelize'
-import {
-  AllowNull,
-  BelongsTo,
-  Column,
-  CreatedAt,
-  DataType,
-  Default,
-  ForeignKey,
-  Is,
-  Model,
-  Scopes,
-  Table,
-  UpdatedAt
-} from 'sequelize-typescript'
-import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
-import {
-  VideoAbuseState,
-  VideoDetails,
-  VideoAbusePredefinedReasons,
-  VideoAbusePredefinedReasonsString,
-  videoAbusePredefinedReasonsMap
-} from '../../../shared'
-import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
-import { VideoAbuse } from '../../../shared/models/videos'
-import {
-  isVideoAbuseModerationCommentValid,
-  isVideoAbuseReasonValid,
-  isVideoAbuseStateValid
-} from '../../helpers/custom-validators/video-abuses'
-import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
-import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../types/models'
-import { AccountModel } from '../account/account'
-import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
-import { ThumbnailModel } from './thumbnail'
-import { VideoModel } from './video'
-import { VideoBlacklistModel } from './video-blacklist'
-import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
-import { invert } from 'lodash'
-
-export enum ScopeNames {
-  FOR_API = 'FOR_API'
-}
-
-@Scopes(() => ({
-  [ScopeNames.FOR_API]: (options: {
-    // search
-    search?: string
-    searchReporter?: string
-    searchReportee?: string
-    searchVideo?: string
-    searchVideoChannel?: string
-
-    // filters
-    id?: number
-    predefinedReasonId?: number
-
-    state?: VideoAbuseState
-    videoIs?: VideoAbuseVideoIs
-
-    // accountIds
-    serverAccountId: number
-    userAccountId: number
-  }) => {
-    const where = {
-      reporterAccountId: {
-        [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
-      }
-    }
-
-    if (options.search) {
-      Object.assign(where, {
-        [Op.or]: [
-          {
-            [Op.and]: [
-              { videoId: { [Op.not]: null } },
-              searchAttribute(options.search, '$Video.name$')
-            ]
-          },
-          {
-            [Op.and]: [
-              { videoId: { [Op.not]: null } },
-              searchAttribute(options.search, '$Video.VideoChannel.name$')
-            ]
-          },
-          {
-            [Op.and]: [
-              { deletedVideo: { [Op.not]: null } },
-              { deletedVideo: searchAttribute(options.search, 'name') }
-            ]
-          },
-          {
-            [Op.and]: [
-              { deletedVideo: { [Op.not]: null } },
-              { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } }
-            ]
-          },
-          searchAttribute(options.search, '$Account.name$')
-        ]
-      })
-    }
-
-    if (options.id) Object.assign(where, { id: options.id })
-    if (options.state) Object.assign(where, { state: options.state })
-
-    if (options.videoIs === 'deleted') {
-      Object.assign(where, {
-        deletedVideo: {
-          [Op.not]: null
-        }
-      })
-    }
-
-    if (options.predefinedReasonId) {
-      Object.assign(where, {
-        predefinedReasons: {
-          [Op.contains]: [ options.predefinedReasonId ]
-        }
-      })
-    }
-
-    const onlyBlacklisted = options.videoIs === 'blacklisted'
-
-    return {
-      attributes: {
-        include: [
-          [
-            // we don't care about this count for deleted videos, so there are not included
-            literal(
-              '(' +
-                'SELECT count(*) ' +
-                'FROM "videoAbuse" ' +
-                'WHERE "videoId" = "VideoAbuseModel"."videoId" ' +
-              ')'
-            ),
-            'countReportsForVideo'
-          ],
-          [
-            // we don't care about this count for deleted videos, so there are not included
-            literal(
-              '(' +
-                'SELECT t.nth ' +
-                'FROM ( ' +
-                  'SELECT id, ' +
-                         'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
-                  'FROM "videoAbuse" ' +
-                ') t ' +
-                'WHERE t.id = "VideoAbuseModel".id ' +
-              ')'
-            ),
-            'nthReportForVideo'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count("videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
-                'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' +
-              ')'
-            ),
-            'countReportsForReporter__video'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` +
-              ')'
-            ),
-            'countReportsForReporter__deletedVideo'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'INNER JOIN "account" ON ' +
-                      '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' +
-                   `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
-              ')'
-            ),
-            'countReportsForReportee__video'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` +
-                   `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
-                      `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
-              ')'
-            ),
-            'countReportsForReportee__deletedVideo'
-          ]
-        ]
-      },
-      include: [
-        {
-          model: AccountModel,
-          required: true,
-          where: searchAttribute(options.searchReporter, 'name')
-        },
-        {
-          model: VideoModel,
-          required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel),
-          where: searchAttribute(options.searchVideo, 'name'),
-          include: [
-            {
-              model: ThumbnailModel
-            },
-            {
-              model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
-              where: searchAttribute(options.searchVideoChannel, 'name'),
-              include: [
-                {
-                  model: AccountModel,
-                  where: searchAttribute(options.searchReportee, 'name')
-                }
-              ]
-            },
-            {
-              attributes: [ 'id', 'reason', 'unfederated' ],
-              model: VideoBlacklistModel,
-              required: onlyBlacklisted
-            }
-          ]
-        }
-      ],
-      where
-    }
-  }
-}))
-@Table({
-  tableName: 'videoAbuse',
-  indexes: [
-    {
-      fields: [ 'videoId' ]
-    },
-    {
-      fields: [ 'reporterAccountId' ]
-    }
-  ]
-})
-export class VideoAbuseModel extends Model<VideoAbuseModel> {
-
-  @AllowNull(false)
-  @Default(null)
-  @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
-  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
-  reason: string
-
-  @AllowNull(false)
-  @Default(null)
-  @Is('VideoAbuseState', value => throwIfNotValid(value, isVideoAbuseStateValid, 'state'))
-  @Column
-  state: VideoAbuseState
-
-  @AllowNull(true)
-  @Default(null)
-  @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
-  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
-  moderationComment: string
-
-  @AllowNull(true)
-  @Default(null)
-  @Column(DataType.JSONB)
-  deletedVideo: VideoDetails
-
-  @AllowNull(true)
-  @Default(null)
-  @Column(DataType.ARRAY(DataType.INTEGER))
-  predefinedReasons: VideoAbusePredefinedReasons[]
-
-  @AllowNull(true)
-  @Default(null)
-  @Column
-  startAt: number
-
-  @AllowNull(true)
-  @Default(null)
-  @Column
-  endAt: number
-
-  @CreatedAt
-  createdAt: Date
-
-  @UpdatedAt
-  updatedAt: Date
-
-  @ForeignKey(() => AccountModel)
-  @Column
-  reporterAccountId: number
-
-  @BelongsTo(() => AccountModel, {
-    foreignKey: {
-      allowNull: true
-    },
-    onDelete: 'set null'
-  })
-  Account: AccountModel
-
-  @ForeignKey(() => VideoModel)
-  @Column
-  videoId: number
-
-  @BelongsTo(() => VideoModel, {
-    foreignKey: {
-      allowNull: true
-    },
-    onDelete: 'set null'
-  })
-  Video: VideoModel
-
-  static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> {
-    const videoAttributes = {}
-    if (videoId) videoAttributes['videoId'] = videoId
-    if (uuid) videoAttributes['deletedVideo'] = { uuid }
-
-    const query = {
-      where: {
-        id,
-        ...videoAttributes
-      }
-    }
-    return VideoAbuseModel.findOne(query)
-  }
-
-  static listForApi (parameters: {
-    start: number
-    count: number
-    sort: string
-
-    serverAccountId: number
-    user?: MUserAccountId
-
-    id?: number
-    predefinedReason?: VideoAbusePredefinedReasonsString
-    state?: VideoAbuseState
-    videoIs?: VideoAbuseVideoIs
-
-    search?: string
-    searchReporter?: string
-    searchReportee?: string
-    searchVideo?: string
-    searchVideoChannel?: string
-  }) {
-    const {
-      start,
-      count,
-      sort,
-      search,
-      user,
-      serverAccountId,
-      state,
-      videoIs,
-      predefinedReason,
-      searchReportee,
-      searchVideo,
-      searchVideoChannel,
-      searchReporter,
-      id
-    } = parameters
-
-    const userAccountId = user ? user.Account.id : undefined
-    const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined
-
-    const query = {
-      offset: start,
-      limit: count,
-      order: getSort(sort),
-      col: 'VideoAbuseModel.id',
-      distinct: true
-    }
-
-    const filters = {
-      id,
-      predefinedReasonId,
-      search,
-      state,
-      videoIs,
-      searchReportee,
-      searchVideo,
-      searchVideoChannel,
-      searchReporter,
-      serverAccountId,
-      userAccountId
-    }
-
-    return VideoAbuseModel
-      .scope([
-        { method: [ ScopeNames.FOR_API, filters ] }
-      ])
-      .findAndCountAll(query)
-      .then(({ rows, count }) => {
-        return { total: count, data: rows }
-      })
-  }
-
-  toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
-    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
-    const countReportsForVideo = this.get('countReportsForVideo') as number
-    const nthReportForVideo = this.get('nthReportForVideo') as number
-    const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
-    const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number
-    const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
-    const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
-
-    const video = this.Video
-      ? this.Video
-      : this.deletedVideo
-
-    return {
-      id: this.id,
-      reason: this.reason,
-      predefinedReasons,
-      reporterAccount: this.Account.toFormattedJSON(),
-      state: {
-        id: this.state,
-        label: VideoAbuseModel.getStateLabel(this.state)
-      },
-      moderationComment: this.moderationComment,
-      video: {
-        id: video.id,
-        uuid: video.uuid,
-        name: video.name,
-        nsfw: video.nsfw,
-        deleted: !this.Video,
-        blacklisted: this.Video?.isBlacklisted() || false,
-        thumbnailPath: this.Video?.getMiniatureStaticPath(),
-        channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel
-      },
-      createdAt: this.createdAt,
-      updatedAt: this.updatedAt,
-      startAt: this.startAt,
-      endAt: this.endAt,
-      count: countReportsForVideo || 0,
-      nth: nthReportForVideo || 0,
-      countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
-      countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0)
-    }
-  }
-
-  toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject {
-    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
-
-    const startAt = this.startAt
-    const endAt = this.endAt
-
-    return {
-      type: 'Flag' as 'Flag',
-      content: this.reason,
-      object: this.Video.url,
-      tag: predefinedReasons.map(r => ({
-        type: 'Hashtag' as 'Hashtag',
-        name: r
-      })),
-      startAt,
-      endAt
-    }
-  }
-
-  private static getStateLabel (id: number) {
-    return VIDEO_ABUSE_STATES[id] || 'Unknown'
-  }
-
-  private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] {
-    return (predefinedReasons || [])
-      .filter(r => r in VideoAbusePredefinedReasons)
-      .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString)
-  }
-}
index 9cee64229a54603ec79aebd88e575ae7ad26fe11..03a3cdf81da8c7c7f6d84cc89666bbc67936cbbe 100644 (file)
@@ -61,6 +61,7 @@ type AvailableWithStatsOptions = {
 }
 
 export type SummaryOptions = {
+  actorRequired?: boolean // Default: true
   withAccount?: boolean // Default: false
   withAccountBlockerIds?: number[]
 }
@@ -121,7 +122,7 @@ export type SummaryOptions = {
         {
           attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
           model: ActorModel.unscoped(),
-          required: true,
+          required: options.actorRequired ?? true,
           include: [
             {
               attributes: [ 'host' ],
index 90625d987a9f6436ecf125f3360ee9a8fe544a1b..75b914b8c8f7fd6f8c49e06bb14375c166d829ef 100644 (file)
@@ -1,7 +1,20 @@
 import * as Bluebird from 'bluebird'
 import { uniq } from 'lodash'
 import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
-import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import {
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  ForeignKey,
+  HasMany,
+  Is,
+  Model,
+  Scopes,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
 import { getServerActor } from '@server/models/application/application'
 import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
 import { VideoPrivacy } from '@shared/models'
@@ -24,6 +37,7 @@ import {
   MCommentOwnerVideoReply,
   MVideoImmutable
 } from '../../types/models/video'
+import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
 import { AccountModel } from '../account/account'
 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
 import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
@@ -224,6 +238,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
   })
   Account: AccountModel
 
+  @HasMany(() => VideoCommentAbuseModel, {
+    foreignKey: {
+      name: 'videoCommentId',
+      allowNull: true
+    },
+    onDelete: 'set null'
+  })
+  CommentAbuses: VideoCommentAbuseModel[]
+
   static loadById (id: number, t?: Transaction): Bluebird<MComment> {
     const query: FindOptions = {
       where: {
@@ -632,7 +655,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       id: this.id,
       url: this.url,
       text: this.text,
-      threadId: this.originCommentId || this.id,
+      threadId: this.getThreadId(),
       inReplyToCommentId: this.inReplyToCommentId || null,
       videoId: this.videoId,
       createdAt: this.createdAt,
index 984b0e6af2db32a25727f827a7fa01b906cb2b7c..466890364522889f5c9074f18961b0fedb5f6eb0 100644 (file)
@@ -327,7 +327,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
         attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
       }
 
-      order = buildOrder(model, options.sort)
+      order = buildOrder(options.sort)
       suffix += `${order} `
     }
 
@@ -357,7 +357,7 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions)
   return { query, replacements, order }
 }
 
-function buildOrder (model: typeof Model, value: string) {
+function buildOrder (value: string) {
   const { direction, field } = buildDirectionAndField(value)
   if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
 
index e2718300e4f1d86b1cd95e231ab0954a7aa6b6bf..43609587cf6f77a233e4c5af22d1cd18eccfe9a0 100644 (file)
@@ -1,4 +1,5 @@
 import * as Bluebird from 'bluebird'
+import { remove } from 'fs-extra'
 import { maxBy, minBy, pick } from 'lodash'
 import { join } from 'path'
 import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
@@ -23,10 +24,18 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { UserRight, VideoPrivacy, VideoState, ResultList } from '../../../shared'
+import { buildNSFWFilter } from '@server/helpers/express-utils'
+import { getPrivaciesForFederation, isPrivacyForFederation } from '@server/helpers/video'
+import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
+import { getServerActor } from '@server/models/application/application'
+import { ModelCache } from '@server/models/model-cache'
+import { VideoFile } from '@shared/models/videos/video-file.model'
+import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
 import { Video, VideoDetails } from '../../../shared/models/videos'
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
 import { VideoFilter } from '../../../shared/models/videos/video-query.type'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
 import { peertubeTruncate } from '../../helpers/core-utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { isBooleanValid } from '../../helpers/custom-validators/misc'
@@ -43,6 +52,7 @@ import {
 } from '../../helpers/custom-validators/videos'
 import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
 import { logger } from '../../helpers/logger'
+import { CONFIG } from '../../initializers/config'
 import {
   ACTIVITY_PUB,
   API_VERSION,
@@ -59,40 +69,6 @@ import {
   WEBSERVER
 } from '../../initializers/constants'
 import { sendDeleteVideo } from '../../lib/activitypub/send'
-import { AccountModel } from '../account/account'
-import { AccountVideoRateModel } from '../account/account-video-rate'
-import { ActorModel } from '../activitypub/actor'
-import { AvatarModel } from '../avatar/avatar'
-import { ServerModel } from '../server/server'
-import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
-import { TagModel } from './tag'
-import { VideoAbuseModel } from './video-abuse'
-import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
-import { VideoCommentModel } from './video-comment'
-import { VideoFileModel } from './video-file'
-import { VideoShareModel } from './video-share'
-import { VideoTagModel } from './video-tag'
-import { ScheduleVideoUpdateModel } from './schedule-video-update'
-import { VideoCaptionModel } from './video-caption'
-import { VideoBlacklistModel } from './video-blacklist'
-import { remove } from 'fs-extra'
-import { VideoViewModel } from './video-view'
-import { VideoRedundancyModel } from '../redundancy/video-redundancy'
-import {
-  videoFilesModelToFormattedJSON,
-  VideoFormattingJSONOptions,
-  videoModelToActivityPubObject,
-  videoModelToFormattedDetailsJSON,
-  videoModelToFormattedJSON
-} from './video-format-utils'
-import { UserVideoHistoryModel } from '../account/user-video-history'
-import { VideoImportModel } from './video-import'
-import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
-import { VideoPlaylistElementModel } from './video-playlist-element'
-import { CONFIG } from '../../initializers/config'
-import { ThumbnailModel } from './thumbnail'
-import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
-import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
 import {
   MChannel,
   MChannelAccountDefault,
@@ -118,15 +94,39 @@ import {
   MVideoWithFile,
   MVideoWithRights
 } from '../../types/models'
-import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
 import { MThumbnail } from '../../types/models/video/thumbnail'
-import { VideoFile } from '@shared/models/videos/video-file.model'
-import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
-import { ModelCache } from '@server/models/model-cache'
+import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
+import { VideoAbuseModel } from '../abuse/video-abuse'
+import { AccountModel } from '../account/account'
+import { AccountVideoRateModel } from '../account/account-video-rate'
+import { UserVideoHistoryModel } from '../account/user-video-history'
+import { ActorModel } from '../activitypub/actor'
+import { AvatarModel } from '../avatar/avatar'
+import { VideoRedundancyModel } from '../redundancy/video-redundancy'
+import { ServerModel } from '../server/server'
+import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
+import { ScheduleVideoUpdateModel } from './schedule-video-update'
+import { TagModel } from './tag'
+import { ThumbnailModel } from './thumbnail'
+import { VideoBlacklistModel } from './video-blacklist'
+import { VideoCaptionModel } from './video-caption'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
+import { VideoCommentModel } from './video-comment'
+import { VideoFileModel } from './video-file'
+import {
+  videoFilesModelToFormattedJSON,
+  VideoFormattingJSONOptions,
+  videoModelToActivityPubObject,
+  videoModelToFormattedDetailsJSON,
+  videoModelToFormattedJSON
+} from './video-format-utils'
+import { VideoImportModel } from './video-import'
+import { VideoPlaylistElementModel } from './video-playlist-element'
 import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
-import { buildNSFWFilter } from '@server/helpers/express-utils'
-import { getServerActor } from '@server/models/application/application'
-import { getPrivaciesForFederation, isPrivacyForFederation } from "@server/helpers/video"
+import { VideoShareModel } from './video-share'
+import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
+import { VideoTagModel } from './video-tag'
+import { VideoViewModel } from './video-view'
 
 export enum ScopeNames {
   AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
@@ -803,14 +803,14 @@ export class VideoModel extends Model<VideoModel> {
   static async saveEssentialDataToAbuses (instance: VideoModel, options) {
     const tasks: Promise<any>[] = []
 
-    logger.info('Saving video abuses details of video %s.', instance.url)
-
     if (!Array.isArray(instance.VideoAbuses)) {
       instance.VideoAbuses = await instance.$get('VideoAbuses')
 
       if (instance.VideoAbuses.length === 0) return undefined
     }
 
+    logger.info('Saving video abuses details of video %s.', instance.url)
+
     const details = instance.toFormattedDetailsJSON()
 
     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 (file)
index 0000000..8964c0a
--- /dev/null
@@ -0,0 +1,269 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import { AbuseCreate, AbuseState } from '@shared/models'
+import {
+  cleanupTests,
+  createUser,
+  deleteAbuse,
+  flushAndRunServer,
+  makeGetRequest,
+  makePostBodyRequest,
+  ServerInfo,
+  setAccessTokensToServers,
+  updateAbuse,
+  uploadVideo,
+  userLogin
+} from '../../../../shared/extra-utils'
+import {
+  checkBadCountPagination,
+  checkBadSortPagination,
+  checkBadStartPagination
+} from '../../../../shared/extra-utils/requests/check-api-params'
+
+describe('Test abuses API validators', function () {
+  const basePath = '/api/v1/abuses/'
+
+  let server: ServerInfo
+  let userAccessToken = ''
+  let abuseId: number
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await flushAndRunServer(1)
+
+    await setAccessTokensToServers([ server ])
+
+    const username = 'user1'
+    const password = 'my super password'
+    await createUser({ url: server.url, accessToken: server.accessToken, username: username, password: password })
+    userAccessToken = await userLogin(server, { username, password })
+
+    const res = await uploadVideo(server.url, server.accessToken, {})
+    server.video = res.body.video
+  })
+
+  describe('When listing abuses', function () {
+    const path = basePath
+
+    it('Should fail with a bad start pagination', async function () {
+      await checkBadStartPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a bad count pagination', async function () {
+      await checkBadCountPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with an incorrect sort', async function () {
+      await checkBadSortPagination(server.url, path, server.accessToken)
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should fail with a non admin user', async function () {
+      await makeGetRequest({
+        url: server.url,
+        path,
+        token: userAccessToken,
+        statusCodeExpected: 403
+      })
+    })
+
+    it('Should fail with a bad id filter', async function () {
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { id: 'toto' } })
+    })
+
+    it('Should fail with a bad filter', async function () {
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'toto' } })
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'videos' } })
+    })
+
+    it('Should fail with bad predefined reason', async function () {
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { predefinedReason: 'violentOrRepulsives' } })
+    })
+
+    it('Should fail with a bad state filter', async function () {
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 'toto' } })
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 0 } })
+    })
+
+    it('Should fail with a bad videoIs filter', async function () {
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { videoIs: 'toto' } })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      const query = {
+        id: 13,
+        predefinedReason: 'violentOrRepulsive',
+        filter: 'comment',
+        state: 2,
+        videoIs: 'deleted'
+      }
+
+      await makeGetRequest({ url: server.url, path, token: server.accessToken, query, statusCodeExpected: 200 })
+    })
+  })
+
+  describe('When reporting an abuse', function () {
+    const path = basePath
+
+    it('Should fail with nothing', async function () {
+      const fields = {}
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with a wrong video', async function () {
+      const fields = { video: { id: 'blabla' }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with an unknown video', async function () {
+      const fields = { video: { id: 42 }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
+    })
+
+    it('Should fail with a wrong comment', async function () {
+      const fields = { comment: { id: 'blabla' }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with an unknown comment', async function () {
+      const fields = { comment: { id: 42 }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
+    })
+
+    it('Should fail with a wrong account', async function () {
+      const fields = { account: { id: 'blabla' }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with an unknown account', async function () {
+      const fields = { account: { id: 42 }, reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 })
+    })
+
+    it('Should fail with not account, comment or video', async function () {
+      const fields = { reason: 'my super reason' }
+      await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 400 })
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      const fields = { video: { id: server.video.id }, reason: 'my super reason' }
+
+      await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 })
+    })
+
+    it('Should fail with a reason too short', async function () {
+      const fields = { video: { id: server.video.id }, reason: 'h' }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with a too big reason', async function () {
+      const fields = { video: { id: server.video.id }, reason: 'super'.repeat(605) }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should succeed with the correct parameters (basic)', async function () {
+      const fields: AbuseCreate = { video: { id: server.video.id }, reason: 'my super reason' }
+
+      const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
+      abuseId = res.body.abuse.id
+    })
+
+    it('Should fail with a wrong predefined reason', async function () {
+      const fields = { video: { id: server.video.id }, reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail with negative timestamps', async function () {
+      const fields = { video: { id: server.video.id, startAt: -1 }, reason: 'my super reason' }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should fail mith misordered startAt/endAt', async function () {
+      const fields = { video: { id: server.video.id, startAt: 5, endAt: 1 }, reason: 'my super reason' }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should succeed with the corret parameters (advanced)', async function () {
+      const fields: AbuseCreate = {
+        video: {
+          id: server.video.id,
+          startAt: 1,
+          endAt: 5
+        },
+        reason: 'my super reason',
+        predefinedReasons: [ 'serverRules' ]
+      }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
+    })
+  })
+
+  describe('When updating an abuse', function () {
+
+    it('Should fail with a non authenticated user', async function () {
+      await updateAbuse(server.url, 'blabla', abuseId, {}, 401)
+    })
+
+    it('Should fail with a non admin user', async function () {
+      await updateAbuse(server.url, userAccessToken, abuseId, {}, 403)
+    })
+
+    it('Should fail with a bad abuse id', async function () {
+      await updateAbuse(server.url, server.accessToken, 45, {}, 404)
+    })
+
+    it('Should fail with a bad state', async function () {
+      const body = { state: 5 }
+      await updateAbuse(server.url, server.accessToken, abuseId, body, 400)
+    })
+
+    it('Should fail with a bad moderation comment', async function () {
+      const body = { moderationComment: 'b'.repeat(3001) }
+      await updateAbuse(server.url, server.accessToken, abuseId, body, 400)
+    })
+
+    it('Should succeed with the correct params', async function () {
+      const body = { state: AbuseState.ACCEPTED }
+      await updateAbuse(server.url, server.accessToken, abuseId, body)
+    })
+  })
+
+  describe('When deleting a video abuse', function () {
+
+    it('Should fail with a non authenticated user', async function () {
+      await deleteAbuse(server.url, 'blabla', abuseId, 401)
+    })
+
+    it('Should fail with a non admin user', async function () {
+      await deleteAbuse(server.url, userAccessToken, abuseId, 403)
+    })
+
+    it('Should fail with a bad abuse id', async function () {
+      await deleteAbuse(server.url, server.accessToken, 45, 404)
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await deleteAbuse(server.url, server.accessToken, abuseId)
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 93ffd98b199c90d5093e035fc230fb38459e7a8b..0ee1f27aaa574362e8bc440c557bca44496c2a26 100644 (file)
@@ -1,3 +1,4 @@
+import './abuses'
 import './accounts'
 import './blocklist'
 import './bulk'
index 2048fa66739bf6875cb445592fd97473ae4b765f..883b1d29c878c15f1bc5f03b7298ed42bcdc20ac 100644 (file)
@@ -164,7 +164,7 @@ describe('Test user notifications API validators', function () {
     const correctFields: UserNotificationSetting = {
       newVideoFromSubscription: UserNotificationSettingValue.WEB,
       newCommentOnMyVideo: UserNotificationSettingValue.WEB,
-      videoAbuseAsModerator: UserNotificationSettingValue.WEB,
+      abuseAsModerator: UserNotificationSettingValue.WEB,
       videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB,
       blacklistOnMyVideo: UserNotificationSettingValue.WEB,
       myVideoImportFinished: UserNotificationSettingValue.WEB,
index 557bf20eb41f79e0699d46fb672d79ed9574fb0f..3b361ca79eb08f2216f78d3992b72b14f7c1d7ce 100644 (file)
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import 'mocha'
-
+import { AbuseState, VideoAbuseCreate } from '@shared/models'
 import {
   cleanupTests,
   createUser,
@@ -20,7 +20,8 @@ import {
   checkBadSortPagination,
   checkBadStartPagination
 } from '../../../../shared/extra-utils/requests/check-api-params'
-import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos'
+
+// FIXME: deprecated in 2.3. Remove this controller
 
 describe('Test video abuses API validators', function () {
   let server: ServerInfo
@@ -136,7 +137,7 @@ describe('Test video abuses API validators', function () {
       const fields = { reason: 'my super reason' }
 
       const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 })
-      videoAbuseId = res.body.videoAbuse.id
+      videoAbuseId = res.body.abuse.id
     })
 
     it('Should fail with a wrong predefined reason', async function () {
@@ -151,12 +152,6 @@ describe('Test video abuses API validators', function () {
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
-    it('Should fail mith misordered startAt/endAt', async function () {
-      const fields = { reason: 'my super reason', startAt: 5, endAt: 1 }
-
-      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
-    })
-
     it('Should succeed with the corret parameters (advanced)', async function () {
       const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 }
 
@@ -190,7 +185,7 @@ describe('Test video abuses API validators', function () {
     })
 
     it('Should succeed with the correct params', async function () {
-      const body = { state: VideoAbuseState.ACCEPTED }
+      const body = { state: AbuseState.ACCEPTED }
       await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body)
     })
   })
index 14a014f07faa4385f2c0d2207320612e18ea36a3..4998de36454793486ce36e7ba54ae35bff114ff2 100644 (file)
@@ -2,6 +2,7 @@
 
 set -eu
 
+activitypubFiles=$(find server/tests/api/moderation -type f | grep -v index.ts | xargs echo)
 redundancyFiles=$(find server/tests/api/redundancy -type f | grep -v index.ts | xargs echo)
 activitypubFiles=$(find server/tests/api/activitypub -type f | grep -v index.ts | xargs echo)
 
index bac77ab2e91ef6421b420be7f23df8f59eb6503c..b62e2f5f756ea4b320296a809cfbd99c97685cb2 100644 (file)
@@ -1,6 +1,7 @@
 // Order of the tests we want to execute
 import './activitypub'
 import './check-params'
+import './moderation'
 import './notifications'
 import './redundancy'
 import './search'
diff --git a/server/tests/api/moderation/abuses.ts b/server/tests/api/moderation/abuses.ts
new file mode 100644 (file)
index 0000000..f186f7e
--- /dev/null
@@ -0,0 +1,777 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import * as chai from 'chai'
+import { Abuse, AbuseFilter, AbusePredefinedReasonsString, AbuseState, VideoComment, Account } from '@shared/models'
+import {
+  addVideoCommentThread,
+  cleanupTests,
+  createUser,
+  deleteAbuse,
+  deleteVideoComment,
+  flushAndRunMultipleServers,
+  getAbusesList,
+  getVideoCommentThreads,
+  getVideoIdFromUUID,
+  getVideosList,
+  immutableAssign,
+  removeVideo,
+  reportAbuse,
+  ServerInfo,
+  setAccessTokensToServers,
+  updateAbuse,
+  uploadVideo,
+  uploadVideoAndGetId,
+  userLogin,
+  getAccount,
+  removeUser,
+  generateUserAccessToken
+} from '../../../../shared/extra-utils/index'
+import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
+import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
+import {
+  addAccountToServerBlocklist,
+  addServerToServerBlocklist,
+  removeAccountFromServerBlocklist,
+  removeServerFromServerBlocklist
+} from '../../../../shared/extra-utils/users/blocklist'
+
+const expect = chai.expect
+
+describe('Test abuses', function () {
+  let servers: ServerInfo[] = []
+  let abuseServer1: Abuse
+  let abuseServer2: Abuse
+
+  before(async function () {
+    this.timeout(50000)
+
+    // Run servers
+    servers = await flushAndRunMultipleServers(2)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  describe('Video abuses', function () {
+
+    before(async function () {
+      this.timeout(50000)
+
+      // Upload some videos on each servers
+      const video1Attributes = {
+        name: 'my super name for server 1',
+        description: 'my super description for server 1'
+      }
+      await uploadVideo(servers[0].url, servers[0].accessToken, video1Attributes)
+
+      const video2Attributes = {
+        name: 'my super name for server 2',
+        description: 'my super description for server 2'
+      }
+      await uploadVideo(servers[1].url, servers[1].accessToken, video2Attributes)
+
+      // Wait videos propagation, server 2 has transcoding enabled
+      await waitJobs(servers)
+
+      const res = await getVideosList(servers[0].url)
+      const videos = res.body.data
+
+      expect(videos.length).to.equal(2)
+
+      servers[0].video = videos.find(video => video.name === 'my super name for server 1')
+      servers[1].video = videos.find(video => video.name === 'my super name for server 2')
+    })
+
+    it('Should not have abuses', async function () {
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+
+      expect(res.body.total).to.equal(0)
+      expect(res.body.data).to.be.an('array')
+      expect(res.body.data.length).to.equal(0)
+    })
+
+    it('Should report abuse on a local video', async function () {
+      this.timeout(15000)
+
+      const reason = 'my super bad reason'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: servers[0].video.id, reason })
+
+      // We wait requests propagation, even if the server 1 is not supposed to make a request to server 2
+      await waitJobs(servers)
+    })
+
+    it('Should have 1 video abuses on server 1 and 0 on server 2', async function () {
+      const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+
+      expect(res1.body.total).to.equal(1)
+      expect(res1.body.data).to.be.an('array')
+      expect(res1.body.data.length).to.equal(1)
+
+      const abuse: Abuse = res1.body.data[0]
+      expect(abuse.reason).to.equal('my super bad reason')
+
+      expect(abuse.reporterAccount.name).to.equal('root')
+      expect(abuse.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse.video.id).to.equal(servers[0].video.id)
+      expect(abuse.video.channel).to.exist
+
+      expect(abuse.comment).to.be.null
+
+      expect(abuse.flaggedAccount.name).to.equal('root')
+      expect(abuse.flaggedAccount.host).to.equal(servers[0].host)
+
+      expect(abuse.video.countReports).to.equal(1)
+      expect(abuse.video.nthReport).to.equal(1)
+
+      expect(abuse.countReportsForReporter).to.equal(1)
+      expect(abuse.countReportsForReportee).to.equal(1)
+
+      const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
+      expect(res2.body.total).to.equal(0)
+      expect(res2.body.data).to.be.an('array')
+      expect(res2.body.data.length).to.equal(0)
+    })
+
+    it('Should report abuse on a remote video', async function () {
+      this.timeout(10000)
+
+      const reason = 'my super bad reason 2'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: servers[1].video.id, reason })
+
+      // We wait requests propagation
+      await waitJobs(servers)
+    })
+
+    it('Should have 2 video abuses on server 1 and 1 on server 2', async function () {
+      const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+
+      expect(res1.body.total).to.equal(2)
+      expect(res1.body.data.length).to.equal(2)
+
+      const abuse1: Abuse = res1.body.data[0]
+      expect(abuse1.reason).to.equal('my super bad reason')
+      expect(abuse1.reporterAccount.name).to.equal('root')
+      expect(abuse1.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse1.video.id).to.equal(servers[0].video.id)
+      expect(abuse1.video.countReports).to.equal(1)
+      expect(abuse1.video.nthReport).to.equal(1)
+
+      expect(abuse1.comment).to.be.null
+
+      expect(abuse1.flaggedAccount.name).to.equal('root')
+      expect(abuse1.flaggedAccount.host).to.equal(servers[0].host)
+
+      expect(abuse1.state.id).to.equal(AbuseState.PENDING)
+      expect(abuse1.state.label).to.equal('Pending')
+      expect(abuse1.moderationComment).to.be.null
+
+      const abuse2: Abuse = res1.body.data[1]
+      expect(abuse2.reason).to.equal('my super bad reason 2')
+
+      expect(abuse2.reporterAccount.name).to.equal('root')
+      expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse2.video.id).to.equal(servers[1].video.id)
+
+      expect(abuse2.comment).to.be.null
+
+      expect(abuse2.flaggedAccount.name).to.equal('root')
+      expect(abuse2.flaggedAccount.host).to.equal(servers[1].host)
+
+      expect(abuse2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuse2.state.label).to.equal('Pending')
+      expect(abuse2.moderationComment).to.be.null
+
+      const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
+      expect(res2.body.total).to.equal(1)
+      expect(res2.body.data.length).to.equal(1)
+
+      abuseServer2 = res2.body.data[0]
+      expect(abuseServer2.reason).to.equal('my super bad reason 2')
+      expect(abuseServer2.reporterAccount.name).to.equal('root')
+      expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse2.flaggedAccount.name).to.equal('root')
+      expect(abuse2.flaggedAccount.host).to.equal(servers[1].host)
+
+      expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuseServer2.state.label).to.equal('Pending')
+      expect(abuseServer2.moderationComment).to.be.null
+    })
+
+    it('Should hide video abuses from blocked accounts', async function () {
+      this.timeout(10000)
+
+      {
+        const videoId = await getVideoIdFromUUID(servers[1].url, servers[0].video.uuid)
+        await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, videoId, reason: 'will mute this' })
+        await waitJobs(servers)
+
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(3)
+      }
+
+      const accountToBlock = 'root@' + servers[1].host
+
+      {
+        await addAccountToServerBlocklist(servers[0].url, servers[0].accessToken, accountToBlock)
+
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(2)
+
+        const abuse = res.body.data.find(a => a.reason === 'will mute this')
+        expect(abuse).to.be.undefined
+      }
+
+      {
+        await removeAccountFromServerBlocklist(servers[0].url, servers[0].accessToken, accountToBlock)
+
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(3)
+      }
+    })
+
+    it('Should hide video abuses from blocked servers', async function () {
+      const serverToBlock = servers[1].host
+
+      {
+        await addServerToServerBlocklist(servers[0].url, servers[0].accessToken, servers[1].host)
+
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(2)
+
+        const abuse = res.body.data.find(a => a.reason === 'will mute this')
+        expect(abuse).to.be.undefined
+      }
+
+      {
+        await removeServerFromServerBlocklist(servers[0].url, servers[0].accessToken, serverToBlock)
+
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(3)
+      }
+    })
+
+    it('Should keep the video abuse when deleting the video', async function () {
+      this.timeout(10000)
+
+      await removeVideo(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid)
+
+      await waitJobs(servers)
+
+      const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
+      expect(res.body.total).to.equal(2, "wrong number of videos returned")
+      expect(res.body.data).to.have.lengthOf(2, "wrong number of videos returned")
+
+      const abuse: Abuse = res.body.data[0]
+      expect(abuse.id).to.equal(abuseServer2.id, "wrong origin server id for first video")
+      expect(abuse.video.id).to.equal(abuseServer2.video.id, "wrong video id")
+      expect(abuse.video.channel).to.exist
+      expect(abuse.video.deleted).to.be.true
+    })
+
+    it('Should include counts of reports from reporter and reportee', async function () {
+      this.timeout(10000)
+
+      // register a second user to have two reporters/reportees
+      const user = { username: 'user2', password: 'password' }
+      await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, ...user })
+      const userAccessToken = await userLogin(servers[0], user)
+
+      // upload a third video via this user
+      const video3Attributes = {
+        name: 'my second super name for server 1',
+        description: 'my second super description for server 1'
+      }
+      await uploadVideo(servers[0].url, userAccessToken, video3Attributes)
+
+      const res1 = await getVideosList(servers[0].url)
+      const videos = res1.body.data
+      const video3 = videos.find(video => video.name === 'my second super name for server 1')
+
+      // resume with the test
+      const reason3 = 'my super bad reason 3'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: video3.id, reason: reason3 })
+
+      const reason4 = 'my super bad reason 4'
+      await reportAbuse({ url: servers[0].url, token: userAccessToken, videoId: servers[0].video.id, reason: reason4 })
+
+      {
+        const res2 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        const abuses = res2.body.data as Abuse[]
+
+        const abuseVideo3 = res2.body.data.find(a => a.video.id === video3.id)
+        expect(abuseVideo3).to.not.be.undefined
+        expect(abuseVideo3.video.countReports).to.equal(1, "wrong reports count for video 3")
+        expect(abuseVideo3.video.nthReport).to.equal(1, "wrong report position in report list for video 3")
+        expect(abuseVideo3.countReportsForReportee).to.equal(1, "wrong reports count for reporter on video 3 abuse")
+        expect(abuseVideo3.countReportsForReporter).to.equal(3, "wrong reports count for reportee on video 3 abuse")
+
+        const abuseServer1 = abuses.find(a => a.video.id === servers[0].video.id)
+        expect(abuseServer1.countReportsForReportee).to.equal(3, "wrong reports count for reporter on video 1 abuse")
+      }
+    })
+
+    it('Should list predefined reasons as well as timestamps for the reported video', async function () {
+      this.timeout(10000)
+
+      const reason5 = 'my super bad reason 5'
+      const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
+      const createdAbuse = (await reportAbuse({
+        url: servers[0].url,
+        token: servers[0].accessToken,
+        videoId: servers[0].video.id,
+        reason: reason5,
+        predefinedReasons: predefinedReasons5,
+        startAt: 1,
+        endAt: 5
+      })).body.abuse
+
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+
+      {
+        const abuse = (res.body.data as Abuse[]).find(a => a.id === createdAbuse.id)
+        expect(abuse.reason).to.equals(reason5)
+        expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported")
+        expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
+        expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
+      }
+    })
+
+    it('Should delete the video abuse', async function () {
+      this.timeout(10000)
+
+      await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
+
+      await waitJobs(servers)
+
+      {
+        const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken })
+        expect(res.body.total).to.equal(1)
+        expect(res.body.data.length).to.equal(1)
+        expect(res.body.data[0].id).to.not.equal(abuseServer2.id)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken })
+        expect(res.body.total).to.equal(6)
+      }
+    })
+
+    it('Should list and filter video abuses', async function () {
+      this.timeout(10000)
+
+      async function list (query: Omit<Parameters<typeof getAbusesList>[0], 'url' | 'token'>) {
+        const options = {
+          url: servers[0].url,
+          token: servers[0].accessToken
+        }
+
+        Object.assign(options, query)
+
+        const res = await getAbusesList(options)
+
+        return res.body.data as Abuse[]
+      }
+
+      expect(await list({ id: 56 })).to.have.lengthOf(0)
+      expect(await list({ id: 1 })).to.have.lengthOf(1)
+
+      expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4)
+      expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0)
+
+      expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1)
+
+      expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4)
+      expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0)
+
+      expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
+      expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5)
+
+      expect(await list({ searchReportee: 'root' })).to.have.lengthOf(5)
+      expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
+
+      expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
+      expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
+
+      expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0)
+      expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6)
+
+      expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
+      expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)
+    })
+  })
+
+  describe('Comment abuses', function () {
+
+    async function getComment (url: string, videoIdArg: number | string) {
+      const videoId = typeof videoIdArg === 'string'
+        ? await getVideoIdFromUUID(url, videoIdArg)
+        : videoIdArg
+
+      const res = await getVideoCommentThreads(url, videoId, 0, 5)
+
+      return res.body.data[0] as VideoComment
+    }
+
+    before(async function () {
+      this.timeout(50000)
+
+      servers[0].video = await uploadVideoAndGetId({ server: servers[0], videoName: 'server 1' })
+      servers[1].video = await uploadVideoAndGetId({ server: servers[1], videoName: 'server 2' })
+
+      await addVideoCommentThread(servers[0].url, servers[0].accessToken, servers[0].video.id, 'comment server 1')
+      await addVideoCommentThread(servers[1].url, servers[1].accessToken, servers[1].video.id, 'comment server 2')
+
+      await waitJobs(servers)
+    })
+
+    it('Should report abuse on a comment', async function () {
+      this.timeout(15000)
+
+      const comment = await getComment(servers[0].url, servers[0].video.id)
+
+      const reason = 'it is a bad comment'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason })
+
+      await waitJobs(servers)
+    })
+
+    it('Should have 1 comment abuse on server 1 and 0 on server 2', async function () {
+      {
+        const comment = await getComment(servers[0].url, servers[0].video.id)
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
+
+        expect(res.body.total).to.equal(1)
+        expect(res.body.data).to.have.lengthOf(1)
+
+        const abuse: Abuse = res.body.data[0]
+        expect(abuse.reason).to.equal('it is a bad comment')
+
+        expect(abuse.reporterAccount.name).to.equal('root')
+        expect(abuse.reporterAccount.host).to.equal(servers[0].host)
+
+        expect(abuse.video).to.be.null
+
+        expect(abuse.comment.deleted).to.be.false
+        expect(abuse.comment.id).to.equal(comment.id)
+        expect(abuse.comment.text).to.equal(comment.text)
+        expect(abuse.comment.video.name).to.equal('server 1')
+        expect(abuse.comment.video.id).to.equal(servers[0].video.id)
+        expect(abuse.comment.video.uuid).to.equal(servers[0].video.uuid)
+
+        expect(abuse.countReportsForReporter).to.equal(5)
+        expect(abuse.countReportsForReportee).to.equal(5)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data.length).to.equal(0)
+      }
+    })
+
+    it('Should report abuse on a remote comment', async function () {
+      this.timeout(10000)
+
+      const comment = await getComment(servers[0].url, servers[1].video.uuid)
+
+      const reason = 'it is a really bad comment'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason })
+
+      await waitJobs(servers)
+    })
+
+    it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () {
+      const commentServer2 = await getComment(servers[0].url, servers[1].video.id)
+
+      const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
+      expect(res1.body.total).to.equal(2)
+      expect(res1.body.data.length).to.equal(2)
+
+      const abuse: Abuse = res1.body.data[0]
+      expect(abuse.reason).to.equal('it is a bad comment')
+      expect(abuse.countReportsForReporter).to.equal(6)
+      expect(abuse.countReportsForReportee).to.equal(5)
+
+      const abuse2: Abuse = res1.body.data[1]
+
+      expect(abuse2.reason).to.equal('it is a really bad comment')
+
+      expect(abuse2.reporterAccount.name).to.equal('root')
+      expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse2.video).to.be.null
+
+      expect(abuse2.comment.deleted).to.be.false
+      expect(abuse2.comment.id).to.equal(commentServer2.id)
+      expect(abuse2.comment.text).to.equal(commentServer2.text)
+      expect(abuse2.comment.video.name).to.equal('server 2')
+      expect(abuse2.comment.video.uuid).to.equal(servers[1].video.uuid)
+
+      expect(abuse2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuse2.state.label).to.equal('Pending')
+
+      expect(abuse2.moderationComment).to.be.null
+
+      expect(abuse2.countReportsForReporter).to.equal(6)
+      expect(abuse2.countReportsForReportee).to.equal(2)
+
+      const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
+      expect(res2.body.total).to.equal(1)
+      expect(res2.body.data.length).to.equal(1)
+
+      abuseServer2 = res2.body.data[0]
+      expect(abuseServer2.reason).to.equal('it is a really bad comment')
+      expect(abuseServer2.reporterAccount.name).to.equal('root')
+      expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuseServer2.state.label).to.equal('Pending')
+
+      expect(abuseServer2.moderationComment).to.be.null
+
+      expect(abuseServer2.countReportsForReporter).to.equal(1)
+      expect(abuseServer2.countReportsForReportee).to.equal(1)
+    })
+
+    it('Should keep the comment abuse when deleting the comment', async function () {
+      this.timeout(10000)
+
+      const commentServer2 = await getComment(servers[0].url, servers[1].video.id)
+
+      await deleteVideoComment(servers[0].url, servers[0].accessToken, servers[1].video.uuid, commentServer2.id)
+
+      await waitJobs(servers)
+
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
+      expect(res.body.total).to.equal(2)
+      expect(res.body.data).to.have.lengthOf(2)
+
+      const abuse = (res.body.data as Abuse[]).find(a => a.comment?.id === commentServer2.id)
+      expect(abuse).to.not.be.undefined
+
+      expect(abuse.comment.text).to.be.empty
+      expect(abuse.comment.video.name).to.equal('server 2')
+      expect(abuse.comment.deleted).to.be.true
+    })
+
+    it('Should delete the comment abuse', async function () {
+      this.timeout(10000)
+
+      await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
+
+      await waitJobs(servers)
+
+      {
+        const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data.length).to.equal(0)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment' })
+        expect(res.body.total).to.equal(2)
+      }
+    })
+
+    it('Should list and filter video abuses', async function () {
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment', searchReportee: 'foo' })
+        expect(res.body.total).to.equal(0)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'comment', searchReportee: 'ot' })
+        expect(res.body.total).to.equal(2)
+      }
+
+      {
+        const baseParams = { url: servers[0].url, token: servers[0].accessToken, filter: 'comment' as AbuseFilter, start: 1, count: 1 }
+
+        const res1 = await getAbusesList(immutableAssign(baseParams, { sort: 'createdAt' }))
+        expect(res1.body.data).to.have.lengthOf(1)
+        expect(res1.body.data[0].comment.text).to.be.empty
+
+        const res2 = await getAbusesList(immutableAssign(baseParams, { sort: '-createdAt' }))
+        expect(res2.body.data).to.have.lengthOf(1)
+        expect(res2.body.data[0].comment.text).to.equal('comment server 1')
+      }
+    })
+  })
+
+  describe('Account abuses', function () {
+
+    async function getAccountFromServer (url: string, name: string, server: ServerInfo) {
+      const res = await getAccount(url, name + '@' + server.host)
+
+      return res.body as Account
+    }
+
+    before(async function () {
+      this.timeout(50000)
+
+      await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: 'user_1', password: 'donald' })
+
+      const token = await generateUserAccessToken(servers[1], 'user_2')
+      await uploadVideo(servers[1].url, token, { name: 'super video' })
+
+      await waitJobs(servers)
+    })
+
+    it('Should report abuse on an account', async function () {
+      this.timeout(15000)
+
+      const account = await getAccountFromServer(servers[0].url, 'user_1', servers[0])
+
+      const reason = 'it is a bad account'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId: account.id, reason })
+
+      await waitJobs(servers)
+    })
+
+    it('Should have 1 account abuse on server 1 and 0 on server 2', async function () {
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
+
+        expect(res.body.total).to.equal(1)
+        expect(res.body.data).to.have.lengthOf(1)
+
+        const abuse: Abuse = res.body.data[0]
+        expect(abuse.reason).to.equal('it is a bad account')
+
+        expect(abuse.reporterAccount.name).to.equal('root')
+        expect(abuse.reporterAccount.host).to.equal(servers[0].host)
+
+        expect(abuse.video).to.be.null
+        expect(abuse.comment).to.be.null
+
+        expect(abuse.flaggedAccount.name).to.equal('user_1')
+        expect(abuse.flaggedAccount.host).to.equal(servers[0].host)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'comment' })
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data.length).to.equal(0)
+      }
+    })
+
+    it('Should report abuse on a remote account', async function () {
+      this.timeout(10000)
+
+      const account = await getAccountFromServer(servers[0].url, 'user_2', servers[1])
+
+      const reason = 'it is a really bad account'
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId: account.id, reason })
+
+      await waitJobs(servers)
+    })
+
+    it('Should have 2 comment abuses on server 1 and 1 on server 2', async function () {
+      const res1 = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
+      expect(res1.body.total).to.equal(2)
+      expect(res1.body.data.length).to.equal(2)
+
+      const abuse: Abuse = res1.body.data[0]
+      expect(abuse.reason).to.equal('it is a bad account')
+
+      const abuse2: Abuse = res1.body.data[1]
+      expect(abuse2.reason).to.equal('it is a really bad account')
+
+      expect(abuse2.reporterAccount.name).to.equal('root')
+      expect(abuse2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuse2.video).to.be.null
+      expect(abuse2.comment).to.be.null
+
+      expect(abuse2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuse2.state.label).to.equal('Pending')
+
+      expect(abuse2.moderationComment).to.be.null
+
+      const res2 = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'account' })
+      expect(res2.body.total).to.equal(1)
+      expect(res2.body.data.length).to.equal(1)
+
+      abuseServer2 = res2.body.data[0]
+
+      expect(abuseServer2.reason).to.equal('it is a really bad account')
+
+      expect(abuseServer2.reporterAccount.name).to.equal('root')
+      expect(abuseServer2.reporterAccount.host).to.equal(servers[0].host)
+
+      expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
+      expect(abuseServer2.state.label).to.equal('Pending')
+
+      expect(abuseServer2.moderationComment).to.be.null
+    })
+
+    it('Should keep the account abuse when deleting the account', async function () {
+      this.timeout(10000)
+
+      const account = await getAccountFromServer(servers[1].url, 'user_2', servers[1])
+      await removeUser(servers[1].url, account.userId, servers[1].accessToken)
+
+      await waitJobs(servers)
+
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
+      expect(res.body.total).to.equal(2)
+      expect(res.body.data).to.have.lengthOf(2)
+
+      const abuse = (res.body.data as Abuse[]).find(a => a.reason === 'it is a really bad account')
+      expect(abuse).to.not.be.undefined
+    })
+
+    it('Should delete the account abuse', async function () {
+      this.timeout(10000)
+
+      await deleteAbuse(servers[1].url, servers[1].accessToken, abuseServer2.id)
+
+      await waitJobs(servers)
+
+      {
+        const res = await getAbusesList({ url: servers[1].url, token: servers[1].accessToken, filter: 'account' })
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data.length).to.equal(0)
+      }
+
+      {
+        const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, filter: 'account' })
+        expect(res.body.total).to.equal(2)
+
+        abuseServer1 = res.body.data[0]
+      }
+    })
+  })
+
+  describe('Common actions on abuses', function () {
+
+    it('Should update the state of an abuse', async function () {
+      const body = { state: AbuseState.REJECTED }
+      await updateAbuse(servers[0].url, servers[0].accessToken, abuseServer1.id, body)
+
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, id: abuseServer1.id })
+      expect(res.body.data[0].state.id).to.equal(AbuseState.REJECTED)
+    })
+
+    it('Should add a moderation comment', async function () {
+      const body = { state: AbuseState.ACCEPTED, moderationComment: 'It is valid' }
+      await updateAbuse(servers[0].url, servers[0].accessToken, abuseServer1.id, body)
+
+      const res = await getAbusesList({ url: servers[0].url, token: servers[0].accessToken, id: abuseServer1.id })
+      expect(res.body.data[0].state.id).to.equal(AbuseState.ACCEPTED)
+      expect(res.body.data[0].moderationComment).to.equal('It is valid')
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
diff --git a/server/tests/api/moderation/index.ts b/server/tests/api/moderation/index.ts
new file mode 100644 (file)
index 0000000..cb018d8
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './abuses'
+export * from './blocklist'
index b90732a7a70ebde17222575bf7a2871c7af665fb..a8517600aa4c8e689c8531a4ef0e42d4e3e06cbb 100644 (file)
@@ -3,15 +3,21 @@
 import 'mocha'
 import { v4 as uuidv4 } from 'uuid'
 import {
+  addVideoCommentThread,
   addVideoToBlacklist,
   cleanupTests,
+  createUser,
   follow,
+  generateUserAccessToken,
+  getAccount,
   getCustomConfig,
+  getVideoCommentThreads,
+  getVideoIdFromUUID,
   immutableAssign,
   MockInstancesIndex,
   registerUser,
   removeVideoFromBlacklist,
-  reportVideoAbuse,
+  reportAbuse,
   unfollow,
   updateCustomConfig,
   updateCustomSubConfig,
@@ -23,7 +29,9 @@ import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
 import {
   checkAutoInstanceFollowing,
   CheckerBaseParams,
+  checkNewAccountAbuseForModerators,
   checkNewBlacklistOnMyVideo,
+  checkNewCommentAbuseForModerators,
   checkNewInstanceFollower,
   checkNewVideoAbuseForModerators,
   checkNewVideoFromSubscription,
@@ -74,12 +82,12 @@ describe('Test moderation notifications', function () {
 
       const name = 'video for abuse ' + uuidv4()
       const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
-      const uuid = resVideo.body.video.uuid
+      const video = resVideo.body.video
 
-      await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason')
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, videoId: video.id, reason: 'super reason' })
 
       await waitJobs(servers)
-      await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence')
+      await checkNewVideoAbuseForModerators(baseParams, video.uuid, name, 'presence')
     })
 
     it('Should send a notification to moderators on remote video abuse', async function () {
@@ -87,14 +95,77 @@ describe('Test moderation notifications', function () {
 
       const name = 'video for abuse ' + uuidv4()
       const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
-      const uuid = resVideo.body.video.uuid
+      const video = resVideo.body.video
+
+      await waitJobs(servers)
+
+      const videoId = await getVideoIdFromUUID(servers[1].url, video.uuid)
+      await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, videoId, reason: 'super reason' })
+
+      await waitJobs(servers)
+      await checkNewVideoAbuseForModerators(baseParams, video.uuid, name, 'presence')
+    })
+
+    it('Should send a notification to moderators on local comment abuse', async function () {
+      this.timeout(10000)
+
+      const name = 'video for abuse ' + uuidv4()
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
+      const video = resVideo.body.video
+      const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4())
+      const comment = resComment.body.comment
+
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, commentId: comment.id, reason: 'super reason' })
+
+      await waitJobs(servers)
+      await checkNewCommentAbuseForModerators(baseParams, video.uuid, name, 'presence')
+    })
+
+    it('Should send a notification to moderators on remote comment abuse', async function () {
+      this.timeout(10000)
+
+      const name = 'video for abuse ' + uuidv4()
+      const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
+      const video = resVideo.body.video
+      await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4())
+
+      await waitJobs(servers)
+
+      const resComments = await getVideoCommentThreads(servers[1].url, video.uuid, 0, 5)
+      const commentId = resComments.body.data[0].id
+      await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, commentId, reason: 'super reason' })
+
+      await waitJobs(servers)
+      await checkNewCommentAbuseForModerators(baseParams, video.uuid, name, 'presence')
+    })
+
+    it('Should send a notification to moderators on local account abuse', async function () {
+      this.timeout(10000)
+
+      const username = 'user' + new Date().getTime()
+      const resUser = await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username, password: 'donald' })
+      const accountId = resUser.body.user.account.id
+
+      await reportAbuse({ url: servers[0].url, token: servers[0].accessToken, accountId, reason: 'super reason' })
+
+      await waitJobs(servers)
+      await checkNewAccountAbuseForModerators(baseParams, username, 'presence')
+    })
+
+    it('Should send a notification to moderators on remote account abuse', async function () {
+      this.timeout(10000)
+
+      const username = 'user' + new Date().getTime()
+      const tmpToken = await generateUserAccessToken(servers[0], username)
+      await uploadVideo(servers[0].url, tmpToken, { name: 'super video' })
 
       await waitJobs(servers)
 
-      await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason')
+      const resAccount = await getAccount(servers[1].url, username + '@' + servers[0].host)
+      await reportAbuse({ url: servers[1].url, token: servers[1].accessToken, accountId: resAccount.body.id, reason: 'super reason' })
 
       await waitJobs(servers)
-      await checkNewVideoAbuseForModerators(baseParams, uuid, name, 'presence')
+      await checkNewAccountAbuseForModerators(baseParams, username, 'presence')
     })
   })
 
index 95b64a45959fd3a95333e15af4b0618619524318..b01a91d48aea093209641062867b210ee85cb0fc 100644 (file)
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import * as chai from 'chai'
 import 'mocha'
+import * as chai from 'chai'
 import {
   addVideoToBlacklist,
   askResetPassword,
@@ -11,7 +11,7 @@ import {
   createUser,
   flushAndRunServer,
   removeVideoFromBlacklist,
-  reportVideoAbuse,
+  reportAbuse,
   resetPassword,
   ServerInfo,
   setAccessTokensToServers,
@@ -30,10 +30,15 @@ describe('Test emails', function () {
   let userId: number
   let userId2: number
   let userAccessToken: string
+
   let videoUUID: string
+  let videoId: number
+
   let videoUserUUID: string
+
   let verificationString: string
   let verificationString2: string
+
   const emails: object[] = []
   const user = {
     username: 'user_1',
@@ -76,6 +81,7 @@ describe('Test emails', function () {
       }
       const res = await uploadVideo(server.url, server.accessToken, attributes)
       videoUUID = res.body.video.uuid
+      videoId = res.body.video.id
     }
   })
 
@@ -174,12 +180,12 @@ describe('Test emails', function () {
     })
   })
 
-  describe('When creating a video abuse', function () {
+  describe('When creating an abuse', function () {
     it('Should send the notification email', async function () {
       this.timeout(10000)
 
       const reason = 'my super bad reason'
-      await reportVideoAbuse(server.url, server.accessToken, videoUUID, reason)
+      await reportAbuse({ url: server.url, token: server.accessToken, videoId, reason })
 
       await waitJobs(server)
       expect(emails).to.have.lengthOf(3)
index fcd022429421654ffacca7226ee8a227fbb9d507..a244a6edbb1cc330edbb946c630cae6a3c2617d8 100644 (file)
@@ -1,5 +1,4 @@
-import './users-verification'
-import './blocklist'
 import './user-subscriptions'
 import './users'
 import './users-multiple-servers'
+import './users-verification'
index 0a66bd1ce5f89f20c497bac4be0e4e52bf7bc998..ea74bde6ad4b1f23feab3e6efeb4f4746a00dfa7 100644 (file)
@@ -1,8 +1,9 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import * as chai from 'chai'
 import 'mocha'
-import { MyUser, User, UserRole, Video, VideoAbuseState, VideoAbuseUpdate, VideoPlaylistType } from '../../../../shared/index'
+import * as chai from 'chai'
+import { AbuseState, AbuseUpdate, MyUser, User, UserRole, Video, VideoPlaylistType } from '@shared/models'
+import { CustomConfig } from '@shared/models/server'
 import {
   addVideoCommentThread,
   blockUser,
@@ -10,6 +11,7 @@ import {
   createUser,
   deleteMe,
   flushAndRunServer,
+  getAbusesList,
   getAccountRatings,
   getBlacklistedVideosList,
   getCustomConfig,
@@ -19,7 +21,6 @@ import {
   getUserInformation,
   getUsersList,
   getUsersListPaginationAndSort,
-  getVideoAbusesList,
   getVideoChannel,
   getVideosList,
   installPlugin,
@@ -29,15 +30,15 @@ import {
   registerUserWithChannel,
   removeUser,
   removeVideo,
-  reportVideoAbuse,
+  reportAbuse,
   ServerInfo,
   testImage,
   unblockUser,
+  updateAbuse,
   updateCustomSubConfig,
   updateMyAvatar,
   updateMyUser,
   updateUser,
-  updateVideoAbuse,
   uploadVideo,
   userLogin,
   waitJobs
@@ -46,7 +47,6 @@ import { follow } from '../../../../shared/extra-utils/server/follows'
 import { logout, serverLogin, setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
 import { getMyVideos } from '../../../../shared/extra-utils/videos/videos'
 import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model'
-import { CustomConfig } from '@shared/models/server'
 
 const expect = chai.expect
 
@@ -302,10 +302,10 @@ describe('Test users', function () {
       expect(userGet.videosCount).to.equal(0)
       expect(userGet.videoCommentsCount).to.be.a('number')
       expect(userGet.videoCommentsCount).to.equal(0)
-      expect(userGet.videoAbusesCount).to.be.a('number')
-      expect(userGet.videoAbusesCount).to.equal(0)
-      expect(userGet.videoAbusesAcceptedCount).to.be.a('number')
-      expect(userGet.videoAbusesAcceptedCount).to.equal(0)
+      expect(userGet.abusesCount).to.be.a('number')
+      expect(userGet.abusesCount).to.equal(0)
+      expect(userGet.abusesAcceptedCount).to.be.a('number')
+      expect(userGet.abusesAcceptedCount).to.equal(0)
     })
   })
 
@@ -895,9 +895,9 @@ describe('Test users', function () {
 
       expect(user.videosCount).to.equal(0)
       expect(user.videoCommentsCount).to.equal(0)
-      expect(user.videoAbusesCount).to.equal(0)
-      expect(user.videoAbusesCreatedCount).to.equal(0)
-      expect(user.videoAbusesAcceptedCount).to.equal(0)
+      expect(user.abusesCount).to.equal(0)
+      expect(user.abusesCreatedCount).to.equal(0)
+      expect(user.abusesAcceptedCount).to.equal(0)
     })
 
     it('Should report correct videos count', async function () {
@@ -924,26 +924,26 @@ describe('Test users', function () {
       expect(user.videoCommentsCount).to.equal(1)
     })
 
-    it('Should report correct video abuses counts', async function () {
+    it('Should report correct abuses counts', async function () {
       const reason = 'my super bad reason'
-      await reportVideoAbuse(server.url, user17AccessToken, videoId, reason)
+      await reportAbuse({ url: server.url, token: user17AccessToken, videoId, reason })
 
-      const res1 = await getVideoAbusesList({ url: server.url, token: server.accessToken })
+      const res1 = await getAbusesList({ url: server.url, token: server.accessToken })
       const abuseId = res1.body.data[0].id
 
       const res2 = await getUserInformation(server.url, server.accessToken, user17Id, true)
       const user2: User = res2.body
 
-      expect(user2.videoAbusesCount).to.equal(1) // number of incriminations
-      expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created
+      expect(user2.abusesCount).to.equal(1) // number of incriminations
+      expect(user2.abusesCreatedCount).to.equal(1) // number of reports created
 
-      const body: VideoAbuseUpdate = { state: VideoAbuseState.ACCEPTED }
-      await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body)
+      const body: AbuseUpdate = { state: AbuseState.ACCEPTED }
+      await updateAbuse(server.url, server.accessToken, abuseId, body)
 
       const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true)
       const user3: User = res3.body
 
-      expect(user3.videoAbusesAcceptedCount).to.equal(1) // number of reports created accepted
+      expect(user3.abusesAcceptedCount).to.equal(1) // number of reports created accepted
     })
   })
 
index 7383bd991c51207a4c1e3e46b8dfc1c76e033f3c..baeb543e0b939ec0b8b33948253f49685f8b8d0c 100644 (file)
@@ -1,21 +1,21 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import * as chai from 'chai'
 import 'mocha'
-import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos'
+import * as chai from 'chai'
+import { Abuse, AbusePredefinedReasonsString, AbuseState } from '@shared/models'
 import {
   cleanupTests,
+  createUser,
   deleteVideoAbuse,
   flushAndRunMultipleServers,
   getVideoAbusesList,
   getVideosList,
+  removeVideo,
   reportVideoAbuse,
   ServerInfo,
   setAccessTokensToServers,
   updateVideoAbuse,
   uploadVideo,
-  removeVideo,
-  createUser,
   userLogin
 } from '../../../../shared/extra-utils/index'
 import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
@@ -29,9 +29,11 @@ import {
 
 const expect = chai.expect
 
+// FIXME: deprecated in 2.3. Remove this controller
+
 describe('Test video abuses', function () {
   let servers: ServerInfo[] = []
-  let abuseServer2: VideoAbuse
+  let abuseServer2: Abuse
 
   before(async function () {
     this.timeout(50000)
@@ -95,14 +97,14 @@ describe('Test video abuses', function () {
     expect(res1.body.data).to.be.an('array')
     expect(res1.body.data.length).to.equal(1)
 
-    const abuse: VideoAbuse = res1.body.data[0]
+    const abuse: Abuse = res1.body.data[0]
     expect(abuse.reason).to.equal('my super bad reason')
     expect(abuse.reporterAccount.name).to.equal('root')
     expect(abuse.reporterAccount.host).to.equal('localhost:' + servers[0].port)
     expect(abuse.video.id).to.equal(servers[0].video.id)
     expect(abuse.video.channel).to.exist
-    expect(abuse.count).to.equal(1)
-    expect(abuse.nth).to.equal(1)
+    expect(abuse.video.countReports).to.equal(1)
+    expect(abuse.video.nthReport).to.equal(1)
     expect(abuse.countReportsForReporter).to.equal(1)
     expect(abuse.countReportsForReportee).to.equal(1)
 
@@ -128,23 +130,23 @@ describe('Test video abuses', function () {
     expect(res1.body.data).to.be.an('array')
     expect(res1.body.data.length).to.equal(2)
 
-    const abuse1: VideoAbuse = res1.body.data[0]
+    const abuse1: Abuse = res1.body.data[0]
     expect(abuse1.reason).to.equal('my super bad reason')
     expect(abuse1.reporterAccount.name).to.equal('root')
     expect(abuse1.reporterAccount.host).to.equal('localhost:' + servers[0].port)
     expect(abuse1.video.id).to.equal(servers[0].video.id)
-    expect(abuse1.state.id).to.equal(VideoAbuseState.PENDING)
+    expect(abuse1.state.id).to.equal(AbuseState.PENDING)
     expect(abuse1.state.label).to.equal('Pending')
     expect(abuse1.moderationComment).to.be.null
-    expect(abuse1.count).to.equal(1)
-    expect(abuse1.nth).to.equal(1)
+    expect(abuse1.video.countReports).to.equal(1)
+    expect(abuse1.video.nthReport).to.equal(1)
 
-    const abuse2: VideoAbuse = res1.body.data[1]
+    const abuse2: Abuse = res1.body.data[1]
     expect(abuse2.reason).to.equal('my super bad reason 2')
     expect(abuse2.reporterAccount.name).to.equal('root')
     expect(abuse2.reporterAccount.host).to.equal('localhost:' + servers[0].port)
     expect(abuse2.video.id).to.equal(servers[1].video.id)
-    expect(abuse2.state.id).to.equal(VideoAbuseState.PENDING)
+    expect(abuse2.state.id).to.equal(AbuseState.PENDING)
     expect(abuse2.state.label).to.equal('Pending')
     expect(abuse2.moderationComment).to.be.null
 
@@ -157,25 +159,25 @@ describe('Test video abuses', function () {
     expect(abuseServer2.reason).to.equal('my super bad reason 2')
     expect(abuseServer2.reporterAccount.name).to.equal('root')
     expect(abuseServer2.reporterAccount.host).to.equal('localhost:' + servers[0].port)
-    expect(abuseServer2.state.id).to.equal(VideoAbuseState.PENDING)
+    expect(abuseServer2.state.id).to.equal(AbuseState.PENDING)
     expect(abuseServer2.state.label).to.equal('Pending')
     expect(abuseServer2.moderationComment).to.be.null
   })
 
   it('Should update the state of a video abuse', async function () {
-    const body = { state: VideoAbuseState.REJECTED }
+    const body = { state: AbuseState.REJECTED }
     await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body)
 
     const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken })
-    expect(res.body.data[0].state.id).to.equal(VideoAbuseState.REJECTED)
+    expect(res.body.data[0].state.id).to.equal(AbuseState.REJECTED)
   })
 
   it('Should add a moderation comment', async function () {
-    const body = { state: VideoAbuseState.ACCEPTED, moderationComment: 'It is valid' }
+    const body = { state: AbuseState.ACCEPTED, moderationComment: 'It is valid' }
     await updateVideoAbuse(servers[1].url, servers[1].accessToken, abuseServer2.video.uuid, abuseServer2.id, body)
 
     const res = await getVideoAbusesList({ url: servers[1].url, token: servers[1].accessToken })
-    expect(res.body.data[0].state.id).to.equal(VideoAbuseState.ACCEPTED)
+    expect(res.body.data[0].state.id).to.equal(AbuseState.ACCEPTED)
     expect(res.body.data[0].moderationComment).to.equal('It is valid')
   })
 
@@ -243,7 +245,7 @@ describe('Test video abuses', function () {
     expect(res.body.data.length).to.equal(2, "wrong number of videos returned")
     expect(res.body.data[0].id).to.equal(abuseServer2.id, "wrong origin server id for first video")
 
-    const abuse: VideoAbuse = res.body.data[0]
+    const abuse: Abuse = res.body.data[0]
     expect(abuse.video.id).to.equal(abuseServer2.video.id, "wrong video id")
     expect(abuse.video.channel).to.exist
     expect(abuse.video.deleted).to.be.true
@@ -277,10 +279,10 @@ describe('Test video abuses', function () {
     const res2 = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
 
     {
-      for (const abuse of res2.body.data as VideoAbuse[]) {
+      for (const abuse of res2.body.data as Abuse[]) {
         if (abuse.video.id === video3.id) {
-          expect(abuse.count).to.equal(1, "wrong reports count for video 3")
-          expect(abuse.nth).to.equal(1, "wrong report position in report list for video 3")
+          expect(abuse.video.countReports).to.equal(1, "wrong reports count for video 3")
+          expect(abuse.video.nthReport).to.equal(1, "wrong report position in report list for video 3")
           expect(abuse.countReportsForReportee).to.equal(1, "wrong reports count for reporter on video 3 abuse")
           expect(abuse.countReportsForReporter).to.equal(3, "wrong reports count for reportee on video 3 abuse")
         }
@@ -295,7 +297,7 @@ describe('Test video abuses', function () {
     this.timeout(10000)
 
     const reason5 = 'my super bad reason 5'
-    const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
+    const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
     const createdAbuse = (await reportVideoAbuse(
       servers[0].url,
       servers[0].accessToken,
@@ -304,16 +306,16 @@ describe('Test video abuses', function () {
       predefinedReasons5,
       1,
       5
-    )).body.videoAbuse as VideoAbuse
+    )).body.abuse
 
     const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken })
 
     {
-      const abuse = (res.body.data as VideoAbuse[]).find(a => a.id === createdAbuse.id)
+      const abuse = (res.body.data as Abuse[]).find(a => a.id === createdAbuse.id)
       expect(abuse.reason).to.equals(reason5)
       expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported")
-      expect(abuse.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
-      expect(abuse.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
+      expect(abuse.video.startAt).to.equal(1, "starting timestamp doesn't match the one reported")
+      expect(abuse.video.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
     }
   })
 
@@ -348,7 +350,7 @@ describe('Test video abuses', function () {
 
       const res = await getVideoAbusesList(options)
 
-      return res.body.data as VideoAbuse[]
+      return res.body.data as Abuse[]
     }
 
     expect(await list({ id: 56 })).to.have.lengthOf(0)
@@ -365,14 +367,14 @@ describe('Test video abuses', function () {
     expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
     expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5)
 
-    expect(await list({ searchReportee: 'root' })).to.have.lengthOf(4)
+    expect(await list({ searchReportee: 'root' })).to.have.lengthOf(5)
     expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
 
     expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
     expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
 
-    expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0)
-    expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6)
+    expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0)
+    expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6)
 
     expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
     expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)
index 78b4948ce85f263f84f1fed28f4002fcfd5f7c88..affa17425d0afe4d182003b18fb8db3fd90da0da 100644 (file)
@@ -1,4 +1,5 @@
 export * from './account'
+export * from './moderation'
 export * from './oauth'
 export * from './server'
 export * from './user'
diff --git a/server/types/models/moderation/abuse.ts b/server/types/models/moderation/abuse.ts
new file mode 100644 (file)
index 0000000..a0bf4b0
--- /dev/null
@@ -0,0 +1,103 @@
+import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
+import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
+import { PickWith } from '@shared/core-utils'
+import { AbuseModel } from '../../../models/abuse/abuse'
+import { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl } from '../account'
+import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo, MComment, MCommentVideo } from '../video'
+import { MVideo, MVideoAccountLightBlacklistAllFiles } from '../video/video'
+
+type Use<K extends keyof AbuseModel, M> = PickWith<AbuseModel, K, M>
+type UseVideoAbuse<K extends keyof VideoAbuseModel, M> = PickWith<VideoAbuseModel, K, M>
+type UseCommentAbuse<K extends keyof VideoCommentAbuseModel, M> = PickWith<VideoCommentAbuseModel, K, M>
+
+// ############################################################################
+
+export type MAbuse = Omit<AbuseModel, 'VideoCommentAbuse' | 'VideoAbuse' | 'ReporterAccount' | 'FlaggedAccount' | 'toActivityPubObject'>
+
+export type MVideoAbuse = Omit<VideoAbuseModel, 'Abuse' | 'Video'>
+
+export type MCommentAbuse = Omit<VideoCommentAbuseModel, 'Abuse' | 'VideoComment'>
+
+// ############################################################################
+
+export type MVideoAbuseVideo =
+  MVideoAbuse &
+  UseVideoAbuse<'Video', MVideo>
+
+export type MVideoAbuseVideoUrl =
+  MVideoAbuse &
+  UseVideoAbuse<'Video', MVideoUrl>
+
+export type MVideoAbuseVideoFull =
+  MVideoAbuse &
+  UseVideoAbuse<'Video', MVideoAccountLightBlacklistAllFiles>
+
+export type MVideoAbuseFormattable =
+  MVideoAbuse &
+  UseVideoAbuse<'Video', Pick<MVideoAccountLightBlacklistAllFiles,
+  'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>>
+
+// ############################################################################
+
+export type MCommentAbuseAccount =
+  MCommentAbuse &
+  UseCommentAbuse<'VideoComment', MCommentOwner>
+
+export type MCommentAbuseAccountVideo =
+  MCommentAbuse &
+  UseCommentAbuse<'VideoComment', MCommentOwnerVideo>
+
+export type MCommentAbuseUrl =
+  MCommentAbuse &
+  UseCommentAbuse<'VideoComment', MCommentUrl>
+
+export type MCommentAbuseFormattable =
+  MCommentAbuse &
+  UseCommentAbuse<'VideoComment', MComment & PickWith<MCommentVideo, 'Video', Pick<MVideo, 'id' | 'uuid' | 'name'>>>
+
+// ############################################################################
+
+export type MAbuseId = Pick<AbuseModel, 'id'>
+
+export type MAbuseVideo =
+  MAbuse &
+  Pick<AbuseModel, 'toActivityPubObject'> &
+  Use<'VideoAbuse', MVideoAbuseVideo>
+
+export type MAbuseUrl =
+  MAbuse &
+  Use<'VideoAbuse', MVideoAbuseVideoUrl> &
+  Use<'VideoCommentAbuse', MCommentAbuseUrl>
+
+export type MAbuseAccountVideo =
+  MAbuse &
+  Pick<AbuseModel, 'toActivityPubObject'> &
+  Use<'VideoAbuse', MVideoAbuseVideoFull> &
+  Use<'ReporterAccount', MAccountDefault>
+
+export type MAbuseAP =
+  MAbuse &
+  Pick<AbuseModel, 'toActivityPubObject'> &
+  Use<'ReporterAccount', MAccountUrl> &
+  Use<'FlaggedAccount', MAccountUrl> &
+  Use<'VideoAbuse', MVideoAbuseVideo> &
+  Use<'VideoCommentAbuse', MCommentAbuseAccount>
+
+export type MAbuseFull =
+  MAbuse &
+  Pick<AbuseModel, 'toActivityPubObject'> &
+  Use<'ReporterAccount', MAccountLight> &
+  Use<'FlaggedAccount', MAccountLight> &
+  Use<'VideoAbuse', MVideoAbuseVideoFull> &
+  Use<'VideoCommentAbuse', MCommentAbuseAccountVideo>
+
+// ############################################################################
+
+// Format for API or AP object
+
+export type MAbuseFormattable =
+  MAbuse &
+  Use<'ReporterAccount', MAccountFormattable> &
+  Use<'FlaggedAccount', MAccountFormattable> &
+  Use<'VideoAbuse', MVideoAbuseFormattable> &
+  Use<'VideoCommentAbuse', MCommentAbuseFormattable>
diff --git a/server/types/models/moderation/index.ts b/server/types/models/moderation/index.ts
new file mode 100644 (file)
index 0000000..8bea170
--- /dev/null
@@ -0,0 +1 @@
+export * from './abuse'
index dd3de423b978d5d89084a1581552bae241a99a6d..f59eb726039fd43a3022a57df1802606d2e2a00a 100644 (file)
@@ -1,16 +1,18 @@
-import { UserNotificationModel } from '../../../models/account/user-notification'
+import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
+import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
 import { PickWith, PickWithOpt } from '@shared/core-utils'
-import { VideoModel } from '../../../models/video/video'
+import { AbuseModel } from '../../../models/abuse/abuse'
+import { AccountModel } from '../../../models/account/account'
+import { UserNotificationModel } from '../../../models/account/user-notification'
 import { ActorModel } from '../../../models/activitypub/actor'
-import { ServerModel } from '../../../models/server/server'
+import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 import { AvatarModel } from '../../../models/avatar/avatar'
+import { ServerModel } from '../../../models/server/server'
+import { VideoModel } from '../../../models/video/video'
+import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
 import { VideoChannelModel } from '../../../models/video/video-channel'
-import { AccountModel } from '../../../models/account/account'
 import { VideoCommentModel } from '../../../models/video/video-comment'
-import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
 import { VideoImportModel } from '../../../models/video/video-import'
-import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
 
 type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M>
 
@@ -47,6 +49,18 @@ export module UserNotificationIncludes {
     Pick<VideoAbuseModel, 'id'> &
     PickWith<VideoAbuseModel, 'Video', VideoInclude>
 
+  export type VideoCommentAbuseInclude =
+    Pick<VideoCommentAbuseModel, 'id'> &
+    PickWith<VideoCommentAbuseModel, 'VideoComment',
+    Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId'> &
+    PickWith<VideoCommentModel, 'Video', Pick<VideoModel, 'id' | 'name' | 'uuid'>>>
+
+  export type AbuseInclude =
+    Pick<AbuseModel, 'id'> &
+    PickWith<AbuseModel, 'VideoAbuse', VideoAbuseInclude> &
+    PickWith<AbuseModel, 'VideoCommentAbuse', VideoCommentAbuseInclude> &
+    PickWith<AbuseModel, 'FlaggedAccount', AccountIncludeActor>
+
   export type VideoBlacklistInclude =
     Pick<VideoBlacklistModel, 'id'> &
     PickWith<VideoAbuseModel, 'Video', VideoInclude>
@@ -76,7 +90,7 @@ export module UserNotificationIncludes {
 // ############################################################################
 
 export type MUserNotification =
-  Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'VideoAbuse' | 'VideoBlacklist' |
+  Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'Abuse' | 'VideoBlacklist' |
   'VideoImport' | 'Account' | 'ActorFollow'>
 
 // ############################################################################
@@ -85,7 +99,7 @@ export type UserNotificationModelForApi =
   MUserNotification &
   Use<'Video', UserNotificationIncludes.VideoIncludeChannel> &
   Use<'Comment', UserNotificationIncludes.VideoCommentInclude> &
-  Use<'VideoAbuse', UserNotificationIncludes.VideoAbuseInclude> &
+  Use<'Abuse', UserNotificationIncludes.AbuseInclude> &
   Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> &
   Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> &
   Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &
index bd69c8a4b273e73c52a1910bd4cf0ebfc3587d37..25db23898425675c8215270f2acfb4289be1f566 100644 (file)
@@ -2,7 +2,6 @@ export * from './schedule-video-update'
 export * from './tag'
 export * from './thumbnail'
 export * from './video'
-export * from './video-abuse'
 export * from './video-blacklist'
 export * from './video-caption'
 export * 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 (file)
index 279a87c..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { PickWith } from '@shared/core-utils'
-import { MVideoAccountLightBlacklistAllFiles, MVideo } from './video'
-import { MAccountDefault, MAccountFormattable } from '../account'
-
-type Use<K extends keyof VideoAbuseModel, M> = PickWith<VideoAbuseModel, K, M>
-
-// ############################################################################
-
-export type MVideoAbuse = Omit<VideoAbuseModel, 'Account' | 'Video' | 'toActivityPubObject'>
-
-// ############################################################################
-
-export type MVideoAbuseId = Pick<VideoAbuseModel, 'id'>
-
-export type MVideoAbuseVideo =
-  MVideoAbuse &
-  Pick<VideoAbuseModel, 'toActivityPubObject'> &
-  Use<'Video', MVideo>
-
-export type MVideoAbuseAccountVideo =
-  MVideoAbuse &
-  Pick<VideoAbuseModel, 'toActivityPubObject'> &
-  Use<'Video', MVideoAccountLightBlacklistAllFiles> &
-  Use<'Account', MAccountDefault>
-
-// ############################################################################
-
-// Format for API or AP object
-
-export type MVideoAbuseFormattable =
-  MVideoAbuse &
-  Use<'Account', MAccountFormattable> &
-  Use<'Video', Pick<MVideoAccountLightBlacklistAllFiles,
-  'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>>
index cac801e55229c8a39f104f574116185fe5073bbc..7595e6d86c22ceaafd3bef731f3fd74e1dcc7788 100644 (file)
@@ -1,5 +1,6 @@
 import { RegisterServerAuthExternalOptions } from '@server/types'
 import {
+  MAbuse,
   MAccountBlocklist,
   MActorUrl,
   MStreamingPlaylist,
@@ -26,7 +27,6 @@ import {
   MComment,
   MCommentOwnerVideoReply,
   MUserDefault,
-  MVideoAbuse,
   MVideoBlacklist,
   MVideoCaptionVideo,
   MVideoFullLight,
@@ -77,7 +77,7 @@ declare module 'express' {
 
       videoCaption?: MVideoCaptionVideo
 
-      videoAbuse?: MVideoAbuse
+      abuse?: MAbuse
 
       videoStreamingPlaylist?: MStreamingPlaylist
 
index 2ac0c6338500e04014311fd3e524621f65a45840..af4d23856607fb9f611b60549c33d02a5d12f63c 100644 (file)
@@ -17,6 +17,7 @@ export * from './videos/services'
 export * from './videos/video-playlists'
 export * from './users/users'
 export * from './users/accounts'
+export * from './moderation/abuses'
 export * from './videos/video-abuses'
 export * from './videos/video-blacklist'
 export * from './videos/video-captions'
diff --git a/shared/extra-utils/moderation/abuses.ts b/shared/extra-utils/moderation/abuses.ts
new file mode 100644 (file)
index 0000000..62af955
--- /dev/null
@@ -0,0 +1,156 @@
+
+import { AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
+import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
+
+function reportAbuse (options: {
+  url: string
+  token: string
+
+  reason: string
+
+  accountId?: number
+  videoId?: number
+  commentId?: number
+
+  predefinedReasons?: AbusePredefinedReasonsString[]
+
+  startAt?: number
+  endAt?: number
+
+  statusCodeExpected?: number
+}) {
+  const path = '/api/v1/abuses'
+
+  const video = options.videoId ? {
+    id: options.videoId,
+    startAt: options.startAt,
+    endAt: options.endAt
+  } : undefined
+
+  const comment = options.commentId ? {
+    id: options.commentId
+  } : undefined
+
+  const account = options.accountId ? {
+    id: options.accountId
+  } : undefined
+
+  const body = {
+    account,
+    video,
+    comment,
+
+    reason: options.reason,
+    predefinedReasons: options.predefinedReasons
+  }
+
+  return makePostBodyRequest({
+    url: options.url,
+    path,
+    token: options.token,
+
+    fields: body,
+    statusCodeExpected: options.statusCodeExpected || 200
+  })
+}
+
+function getAbusesList (options: {
+  url: string
+  token: string
+
+  start?: number
+  count?: number
+  sort?: string
+
+  id?: number
+  predefinedReason?: AbusePredefinedReasonsString
+  search?: string
+  filter?: AbuseFilter
+  state?: AbuseState
+  videoIs?: AbuseVideoIs
+  searchReporter?: string
+  searchReportee?: string
+  searchVideo?: string
+  searchVideoChannel?: string
+}) {
+  const {
+    url,
+    token,
+    start,
+    count,
+    sort,
+    id,
+    predefinedReason,
+    search,
+    filter,
+    state,
+    videoIs,
+    searchReporter,
+    searchReportee,
+    searchVideo,
+    searchVideoChannel
+  } = options
+  const path = '/api/v1/abuses'
+
+  const query = {
+    id,
+    predefinedReason,
+    search,
+    state,
+    filter,
+    videoIs,
+    start,
+    count,
+    sort: sort || 'createdAt',
+    searchReporter,
+    searchReportee,
+    searchVideo,
+    searchVideoChannel
+  }
+
+  return makeGetRequest({
+    url,
+    path,
+    token,
+    query,
+    statusCodeExpected: 200
+  })
+}
+
+function updateAbuse (
+  url: string,
+  token: string,
+  abuseId: number,
+  body: AbuseUpdate,
+  statusCodeExpected = 204
+) {
+  const path = '/api/v1/abuses/' + abuseId
+
+  return makePutBodyRequest({
+    url,
+    token,
+    path,
+    fields: body,
+    statusCodeExpected
+  })
+}
+
+function deleteAbuse (url: string, token: string, abuseId: number, statusCodeExpected = 204) {
+  const path = '/api/v1/abuses/' + abuseId
+
+  return makeDeleteRequest({
+    url,
+    token,
+    path,
+    statusCodeExpected
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  reportAbuse,
+  getAbusesList,
+  updateAbuse,
+  deleteAbuse
+}
index 0f883d839c08bf3eedc71cf19a020dcbf6350228..994aac62896554d79ac0b52d96533ae7e229b595 100644 (file)
@@ -37,8 +37,8 @@ interface ServerInfo {
   video?: {
     id: number
     uuid: string
-    name: string
-    account: {
+    name?: string
+    account?: {
       name: string
     }
   }
index a17a39de9080bbf8dd1bb9b0fbdfa5d45faa8f01..2061e3353b51e03fd17809ec55332c5d29482caa 100644 (file)
@@ -139,13 +139,17 @@ async function checkNotification (
 }
 
 function checkVideo (video: any, videoName?: string, videoUUID?: string) {
-  expect(video.name).to.be.a('string')
-  expect(video.name).to.not.be.empty
-  if (videoName) expect(video.name).to.equal(videoName)
+  if (videoName) {
+    expect(video.name).to.be.a('string')
+    expect(video.name).to.not.be.empty
+    expect(video.name).to.equal(videoName)
+  }
 
-  expect(video.uuid).to.be.a('string')
-  expect(video.uuid).to.not.be.empty
-  if (videoUUID) expect(video.uuid).to.equal(videoUUID)
+  if (videoUUID) {
+    expect(video.uuid).to.be.a('string')
+    expect(video.uuid).to.not.be.empty
+    expect(video.uuid).to.equal(videoUUID)
+  }
 
   expect(video.id).to.be.a('number')
 }
@@ -436,18 +440,43 @@ async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string,
 }
 
 async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
-  const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS
+  const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      expect(notification.abuse.id).to.be.a('number')
+      checkVideo(notification.abuse.video, videoName, videoUUID)
+    } else {
+      expect(notification).to.satisfy((n: UserNotification) => {
+        return n === undefined || n.abuse === undefined || n.abuse.video.uuid !== videoUUID
+      })
+    }
+  }
+
+  function emailNotificationFinder (email: object) {
+    const text = email['text']
+    return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
+  }
+
+  await checkNotification(base, notificationChecker, emailNotificationFinder, type)
+}
+
+async function checkNewCommentAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
 
   function notificationChecker (notification: UserNotification, type: CheckerType) {
     if (type === 'presence') {
       expect(notification).to.not.be.undefined
       expect(notification.type).to.equal(notificationType)
 
-      expect(notification.videoAbuse.id).to.be.a('number')
-      checkVideo(notification.videoAbuse.video, videoName, videoUUID)
+      expect(notification.abuse.id).to.be.a('number')
+      checkVideo(notification.abuse.comment.video, videoName, videoUUID)
     } else {
       expect(notification).to.satisfy((n: UserNotification) => {
-        return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID
+        return n === undefined || n.abuse === undefined || n.abuse.comment.video.uuid !== videoUUID
       })
     }
   }
@@ -460,6 +489,31 @@ async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUU
   await checkNotification(base, notificationChecker, emailNotificationFinder, type)
 }
 
+async function checkNewAccountAbuseForModerators (base: CheckerBaseParams, displayName: string, type: CheckerType) {
+  const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS
+
+  function notificationChecker (notification: UserNotification, type: CheckerType) {
+    if (type === 'presence') {
+      expect(notification).to.not.be.undefined
+      expect(notification.type).to.equal(notificationType)
+
+      expect(notification.abuse.id).to.be.a('number')
+      expect(notification.abuse.account.displayName).to.equal(displayName)
+    } else {
+      expect(notification).to.satisfy((n: UserNotification) => {
+        return n === undefined || n.abuse === undefined || n.abuse.account.displayName !== displayName
+      })
+    }
+  }
+
+  function emailNotificationFinder (email: object) {
+    const text = email['text']
+    return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1
+  }
+
+  await checkNotification(base, notificationChecker, emailNotificationFinder, type)
+}
+
 async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
   const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS
 
@@ -516,7 +570,7 @@ function getAllNotificationsSettings () {
   return {
     newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
-    videoAbuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
+    abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
     myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
@@ -541,6 +595,9 @@ async function prepareNotificationsTest (serversCount = 3) {
     smtp: {
       hostname: 'localhost',
       port
+    },
+    signup: {
+      limit: 20
     }
   }
   const servers = await flushAndRunMultipleServers(serversCount, overrideConfig)
@@ -623,5 +680,7 @@ export {
   markAsReadNotifications,
   getLastNotification,
   checkNewInstanceFollower,
-  prepareNotificationsTest
+  prepareNotificationsTest,
+  checkNewCommentAbuseForModerators,
+  checkNewAccountAbuseForModerators
 }
index ff006672ad99a42a5e44867d09a71771d81a42b3..8827b8196ccad80f15b5df1000330ec8a59d3dc6 100644 (file)
@@ -1,15 +1,15 @@
 import * as request from 'supertest'
-import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model'
-import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests'
-import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models'
-import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
+import { AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
+import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
+
+// FIXME: deprecated in 2.3. Remove this file
 
 function reportVideoAbuse (
   url: string,
   token: string,
   videoId: number | string,
   reason: string,
-  predefinedReasons?: VideoAbusePredefinedReasonsString[],
+  predefinedReasons?: AbusePredefinedReasonsString[],
   startAt?: number,
   endAt?: number,
   specialStatus = 200
@@ -28,10 +28,10 @@ function getVideoAbusesList (options: {
   url: string
   token: string
   id?: number
-  predefinedReason?: VideoAbusePredefinedReasonsString
+  predefinedReason?: AbusePredefinedReasonsString
   search?: string
-  state?: VideoAbuseState
-  videoIs?: VideoAbuseVideoIs
+  state?: AbuseState
+  videoIs?: AbuseVideoIs
   searchReporter?: string
   searchReportee?: string
   searchVideo?: string
@@ -79,7 +79,7 @@ function updateVideoAbuse (
   token: string,
   videoId: string | number,
   videoAbuseId: number,
-  body: VideoAbuseUpdate,
+  body: AbuseUpdate,
   statusCodeExpected = 204
 ) {
   const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
index 31b9e46739718ad82479637ec9ee2a5398e9a52a..5b4ce214a22b98ee5198f9aeb7d683f1a3ff3750 100644 (file)
@@ -1,12 +1,12 @@
 import { ActivityPubActor } from './activitypub-actor'
 import { ActivityPubSignature } from './activitypub-signature'
-import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects'
+import { ActivityFlagReasonObject, CacheFileObject, VideoTorrentObject } from './objects'
+import { AbuseObject } from './objects/abuse-object'
 import { DislikeObject } from './objects/dislike-object'
-import { VideoAbuseObject } from './objects/video-abuse-object'
-import { VideoCommentObject } from './objects/video-comment-object'
-import { ViewObject } from './objects/view-object'
 import { APObject } from './objects/object.model'
 import { PlaylistObject } from './objects/playlist-object'
+import { VideoCommentObject } from './objects/video-comment-object'
+import { ViewObject } from './objects/view-object'
 
 export type Activity =
   ActivityCreate |
@@ -53,7 +53,7 @@ export interface BaseActivity {
 
 export interface ActivityCreate extends BaseActivity {
   type: 'Create'
-  object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
+  object: VideoTorrentObject | AbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
 }
 
 export interface ActivityUpdate extends BaseActivity {
similarity index 84%
rename from shared/models/activitypub/objects/video-abuse-object.ts
rename to shared/models/activitypub/objects/abuse-object.ts
index 73add8ef4479aa99922b2a87d8ec3bce6bb7e005..ad45cc064e7a04255278e640b21d8a89499cbfb2 100644 (file)
@@ -1,10 +1,12 @@
 import { ActivityFlagReasonObject } from './common-objects'
 
-export interface VideoAbuseObject {
+export interface AbuseObject {
   type: 'Flag'
   content: string
   object: string | string[]
+
   tag?: ActivityFlagReasonObject[]
+
   startAt?: number
   endAt?: number
 }
index 096d422eab117a4aaa3f12c30ff75bf64246b2a7..711ce45f45b925a361198df304ae68350eae6533 100644 (file)
@@ -1,4 +1,4 @@
-import { VideoAbusePredefinedReasonsString } from '@shared/models/videos'
+import { AbusePredefinedReasonsString } from '@shared/models'
 
 export interface ActivityIdentifierObject {
   identifier: string
@@ -85,7 +85,7 @@ export interface ActivityMentionObject {
 
 export interface ActivityFlagReasonObject {
   type: 'Hashtag'
-  name: VideoAbusePredefinedReasonsString
+  name: AbusePredefinedReasonsString
 }
 
 export type ActivityTagObject =
index fba61e12fcfb103ba247eff3a2d89da6f175de5e..a6a20e87a01a8572fb64a507707d4d3c46ea6d6e 100644 (file)
@@ -1,6 +1,6 @@
+export * from './abuse-object'
 export * from './cache-file-object'
 export * from './common-objects'
-export * from './video-abuse-object'
+export * from './dislike-object'
 export * from './video-torrent-object'
 export * from './view-object'
-export * from './dislike-object'
index 3d4bdedde40ab4780f8d3212e74a11599f9b6b40..a68f57148d3274b08d4e16303e0a1693cf60f1fb 100644 (file)
@@ -1,7 +1,7 @@
 export * from './activitypub'
 export * from './actors'
 export * from './avatars'
-export * from './blocklist'
+export * from './moderation'
 export * from './bulk'
 export * from './redundancy'
 export * from './users'
@@ -14,4 +14,3 @@ export * from './search'
 export * from './server'
 export * from './oauth-client-local.model'
 export * from './result-list.model'
-export * 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 (file)
index 0000000..b0358db
--- /dev/null
@@ -0,0 +1,29 @@
+import { AbusePredefinedReasonsString } from './abuse-reason.model'
+
+export interface AbuseCreate {
+  reason: string
+
+  predefinedReasons?: AbusePredefinedReasonsString[]
+
+  account?: {
+    id: number
+  }
+
+  video?: {
+    id: number
+    startAt?: number
+    endAt?: number
+  }
+
+  comment?: {
+    id: number
+  }
+}
+
+// FIXME: deprecated in 2.3. Remove it
+export interface VideoAbuseCreate {
+  reason: string
+  predefinedReasons?: AbusePredefinedReasonsString[]
+  startAt?: number
+  endAt?: number
+}
diff --git a/shared/models/moderation/abuse/abuse-filter.type.ts b/shared/models/moderation/abuse/abuse-filter.type.ts
new file mode 100644 (file)
index 0000000..7dafc6d
--- /dev/null
@@ -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 (file)
index 0000000..3687596
--- /dev/null
@@ -0,0 +1,33 @@
+export enum AbusePredefinedReasons {
+  VIOLENT_OR_REPULSIVE = 1,
+  HATEFUL_OR_ABUSIVE,
+  SPAM_OR_MISLEADING,
+  PRIVACY,
+  RIGHTS,
+  SERVER_RULES,
+  THUMBNAILS,
+  CAPTIONS
+}
+
+export type AbusePredefinedReasonsString =
+  'violentOrRepulsive' |
+  'hatefulOrAbusive' |
+  'spamOrMisleading' |
+  'privacy' |
+  'rights' |
+  'serverRules' |
+  'thumbnails' |
+  'captions'
+
+export const abusePredefinedReasonsMap: {
+  [key in AbusePredefinedReasonsString]: AbusePredefinedReasons
+} = {
+  violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
+  hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
+  spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING,
+  privacy: AbusePredefinedReasons.PRIVACY,
+  rights: AbusePredefinedReasons.RIGHTS,
+  serverRules: AbusePredefinedReasons.SERVER_RULES,
+  thumbnails: AbusePredefinedReasons.THUMBNAILS,
+  captions: AbusePredefinedReasons.CAPTIONS
+}
similarity index 61%
rename from shared/models/videos/abuse/video-abuse-state.model.ts
rename to shared/models/moderation/abuse/abuse-state.model.ts
index 529f034bdd048e62c445d82ad5e397bf9d2bdcc8..b00cccad85e777fbeadf5c6f2665277e3a77b604 100644 (file)
@@ -1,4 +1,4 @@
-export enum VideoAbuseState {
+export enum AbuseState {
   PENDING = 1,
   REJECTED = 2,
   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 (file)
index 0000000..4360fe7
--- /dev/null
@@ -0,0 +1,7 @@
+import { AbuseState } from './abuse-state.model'
+
+export interface AbuseUpdate {
+  moderationComment?: string
+
+  state?: AbuseState
+}
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 (file)
index 0000000..74937f3
--- /dev/null
@@ -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 (file)
index 0000000..0a0c6bd
--- /dev/null
@@ -0,0 +1,73 @@
+import { Account } from '../../actors/account.model'
+import { AbuseState } from './abuse-state.model'
+import { AbusePredefinedReasonsString } from './abuse-reason.model'
+import { VideoConstant } from '../../videos/video-constant.model'
+import { VideoChannel } from '../../videos/channel/video-channel.model'
+
+export interface VideoAbuse {
+  id: number
+  name: string
+  uuid: string
+  nsfw: boolean
+
+  deleted: boolean
+  blacklisted: boolean
+
+  startAt: number | null
+  endAt: number | null
+
+  thumbnailPath?: string
+  channel?: VideoChannel
+
+  countReports: number
+  nthReport: number
+}
+
+export interface VideoCommentAbuse {
+  id: number
+  threadId: number
+
+  video: {
+    id: number
+    name: string
+    uuid: string
+  }
+
+  text: string
+
+  deleted: boolean
+}
+
+export interface Abuse {
+  id: number
+
+  reason: string
+  predefinedReasons?: AbusePredefinedReasonsString[]
+
+  reporterAccount: Account
+  flaggedAccount: Account
+
+  state: VideoConstant<AbuseState>
+  moderationComment?: string
+
+  video?: VideoAbuse
+  comment?: VideoCommentAbuse
+
+  createdAt: Date
+  updatedAt: Date
+
+  countReportsForReporter?: number
+  countReportsForReportee?: number
+
+  // FIXME: deprecated in 2.3, remove the following properties
+
+  // @deprecated
+  startAt?: null
+  // @deprecated
+  endAt?: null
+
+  // @deprecated
+  count?: number
+  // @deprecated
+  nth?: number
+}
diff --git a/shared/models/moderation/abuse/index.ts b/shared/models/moderation/abuse/index.ts
new file mode 100644 (file)
index 0000000..5504642
--- /dev/null
@@ -0,0 +1,7 @@
+export * from './abuse-create.model'
+export * from './abuse-filter.type'
+export * from './abuse-reason.model'
+export * from './abuse-state.model'
+export * from './abuse-update.model'
+export * from './abuse-video-is.type'
+export * from './abuse.model'
similarity index 75%
rename from shared/models/blocklist/index.ts
rename to shared/models/moderation/index.ts
index fc78732706b0e2d4cf1ac73e2234d0a57692b2f3..8b6042e9790b55fb986e42fcac258de1b8cb63d6 100644 (file)
@@ -1,2 +1,3 @@
+export * from './abuse'
 export * from './account-block.model'
 export * from './server-block.model'
index 451f40d5841754cdaeeb00918be2e86898937764..4e2230a76e581d4f4c33168ca80ced2b418cc6fd 100644 (file)
@@ -7,7 +7,7 @@ export enum UserNotificationSettingValue {
 export interface UserNotificationSetting {
   newVideoFromSubscription: UserNotificationSettingValue
   newCommentOnMyVideo: UserNotificationSettingValue
-  videoAbuseAsModerator: UserNotificationSettingValue
+  abuseAsModerator: UserNotificationSettingValue
   videoAutoBlacklistAsModerator: UserNotificationSettingValue
   blacklistOnMyVideo: UserNotificationSettingValue
   myVideoPublished: UserNotificationSettingValue
index e9be1ca7fd76c6a7f844f6304dcb6f45c63ad7f8..5f7c3397609df57d6d82efc05dc16b85afae0dae 100644 (file)
@@ -3,7 +3,7 @@ import { FollowState } from '../actors'
 export enum UserNotificationType {
   NEW_VIDEO_FROM_SUBSCRIPTION = 1,
   NEW_COMMENT_ON_MY_VIDEO = 2,
-  NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
+  NEW_ABUSE_FOR_MODERATORS = 3,
 
   BLACKLIST_ON_MY_VIDEO = 4,
   UNBLACKLIST_ON_MY_VIDEO = 5,
@@ -64,9 +64,22 @@ export interface UserNotification {
     video: VideoInfo
   }
 
-  videoAbuse?: {
+  abuse?: {
     id: number
-    video: VideoInfo
+
+    video?: VideoInfo
+
+    comment?: {
+      threadId: number
+
+      video: {
+        id: number
+        uuid: string
+        name: string
+      }
+    }
+
+    account?: ActorInfo
   }
 
   videoBlacklist?: {
index 2f88a65ded827a7c24be60119586bca7f2c9903b..4a7ae43738f54898cbe57410f7f4c27852afc7de 100644 (file)
@@ -11,7 +11,7 @@ export enum UserRight {
 
   MANAGE_SERVER_REDUNDANCY,
 
-  MANAGE_VIDEO_ABUSES,
+  MANAGE_ABUSES,
 
   MANAGE_JOBS,
 
index 2b08b585029901d17c0e417cba6d381f38c1c845..772988c0c5a2ce0471fad1415deb583fe566658b 100644 (file)
@@ -20,7 +20,7 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = {
 
   [UserRole.MODERATOR]: [
     UserRight.MANAGE_VIDEO_BLACKLIST,
-    UserRight.MANAGE_VIDEO_ABUSES,
+    UserRight.MANAGE_ABUSES,
     UserRight.REMOVE_ANY_VIDEO,
     UserRight.REMOVE_ANY_VIDEO_CHANNEL,
     UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
index 6c959ceeaa690256c195400317ec71603e88abbc..859736b2fbf04bf7a51a8f15f40f0f7f991bffab 100644 (file)
@@ -31,10 +31,13 @@ export interface User {
   videoQuotaDaily: number
   videoQuotaUsed?: number
   videoQuotaUsedDaily?: number
+
   videosCount?: number
-  videoAbusesCount?: number
-  videoAbusesAcceptedCount?: number
-  videoAbusesCreatedCount?: number
+
+  abusesCount?: number
+  abusesAcceptedCount?: number
+  abusesCreatedCount?: number
+
   videoCommentsCount? : number
 
   theme: string
diff --git a/shared/models/videos/abuse/index.ts b/shared/models/videos/abuse/index.ts
deleted file mode 100644 (file)
index f70bc73..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-export * from './video-abuse-create.model'
-export * from './video-abuse-reason.model'
-export * from './video-abuse-state.model'
-export * from './video-abuse-update.model'
-export * from './video-abuse-video-is.type'
-export * 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 (file)
index c93cb8b..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
-
-export interface VideoAbuseCreate {
-  reason: string
-  predefinedReasons?: VideoAbusePredefinedReasonsString[]
-  startAt?: number
-  endAt?: number
-}
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 (file)
index 9064f0c..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-export enum VideoAbusePredefinedReasons {
-  VIOLENT_OR_REPULSIVE = 1,
-  HATEFUL_OR_ABUSIVE,
-  SPAM_OR_MISLEADING,
-  PRIVACY,
-  RIGHTS,
-  SERVER_RULES,
-  THUMBNAILS,
-  CAPTIONS
-}
-
-export type VideoAbusePredefinedReasonsString =
-  'violentOrRepulsive' |
-  'hatefulOrAbusive' |
-  'spamOrMisleading' |
-  'privacy' |
-  'rights' |
-  'serverRules' |
-  'thumbnails' |
-  'captions'
-
-export const videoAbusePredefinedReasonsMap: {
-  [key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons
-} = {
-  violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
-  hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
-  spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING,
-  privacy: VideoAbusePredefinedReasons.PRIVACY,
-  rights: VideoAbusePredefinedReasons.RIGHTS,
-  serverRules: VideoAbusePredefinedReasons.SERVER_RULES,
-  thumbnails: VideoAbusePredefinedReasons.THUMBNAILS,
-  captions: VideoAbusePredefinedReasons.CAPTIONS
-}
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 (file)
index 9b32aae..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-import { VideoAbuseState } from './video-abuse-state.model'
-
-export interface VideoAbuseUpdate {
-  moderationComment?: string
-  state?: VideoAbuseState
-}
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 (file)
index e860189..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export 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 (file)
index 38605dc..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Account } from '../../actors/index'
-import { VideoConstant } from '../video-constant.model'
-import { VideoAbuseState } from './video-abuse-state.model'
-import { VideoChannel } from '../channel/video-channel.model'
-import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
-
-export interface VideoAbuse {
-  id: number
-  reason: string
-  predefinedReasons?: VideoAbusePredefinedReasonsString[]
-  reporterAccount: Account
-
-  state: VideoConstant<VideoAbuseState>
-  moderationComment?: string
-
-  video: {
-    id: number
-    name: string
-    uuid: string
-    nsfw: boolean
-    deleted: boolean
-    blacklisted: boolean
-    thumbnailPath?: string
-    channel?: VideoChannel
-  }
-
-  createdAt: Date
-  updatedAt: Date
-
-  startAt: number
-  endAt: number
-
-  count?: number
-  nth?: number
-
-  countReportsForReporter?: number
-  countReportsForReportee?: number
-}
index e1d96b40ad22699db24a25045005b29e24ad6515..20b9638abfcee542a07093bc9691d24282751c40 100644 (file)
@@ -1,4 +1,3 @@
-export * from './abuse'
 export * from './blacklist'
 export * from './caption'
 export * from './channel'
index 79f75063f73b6a79aea86b14aa8b2740c828df61..a0d086324fc8664ae9bf2cae3e5f07fe2bcf47af 100644 (file)
@@ -106,9 +106,9 @@ tags:
       Managing plugins installed from a local path or from NPM, or search for new ones.
     externalDocs:
       url: https://docs.joinpeertube.org/#/api-plugins
-  - name: Video Abuses
+  - name: Abuses
     description: |
-      Video abuses deal with reports of local or remote videos alike.
+      Abuses deal with reports of local or remote videos/comments/accounts alike.
   - name: Video
     description: |
       Operations dealing with listing, uploading, fetching or modifying videos.
@@ -166,7 +166,7 @@ x-tagGroups:
       - Search
   - name: Moderation
     tags:
-      - Video Abuses
+      - Abuses
       - Video Blocks
       - Account Blocks
       - Server Blocks
@@ -893,7 +893,7 @@ paths:
                   $ref: '#/components/schemas/NotificationSettingValue'
                 newCommentOnMyVideo:
                   $ref: '#/components/schemas/NotificationSettingValue'
-                videoAbuseAsModerator:
+                abuseAsModerator:
                   $ref: '#/components/schemas/NotificationSettingValue'
                 videoAutoBlacklistAsModerator:
                   $ref: '#/components/schemas/NotificationSettingValue'
@@ -1471,16 +1471,15 @@ paths:
           description: HTTP or Torrent/magnetURI import not enabled
         '400':
           description: '`magnetUri` or `targetUrl` or a torrent file missing'
-  /videos/abuse:
+  /abuses:
     get:
-      deprecated: true
-      summary: List video abuses
+      summary: List abuses
       security:
         - OAuth2:
           - admin
           - moderator
       tags:
-        - Video Abuses
+        - Abuses
       parameters:
         - name: id
           in: query
@@ -1491,16 +1490,7 @@ paths:
           in: query
           description: predefined reason the listed reports should contain
           schema:
-            type: string
-            enum:
-              - violentOrAbusive
-              - hatefulOrAbusive
-              - spamOrMisleading
-              - privacy
-              - rights
-              - serverRules
-              - thumbnails
-              - captions
+            $ref: '#/components/schemas/PredefinedAbuseReasons'
         - name: search
           in: query
           description: plain search that will match with video titles, reporter names and more
@@ -1508,7 +1498,7 @@ paths:
             type: string
         - name: state
           in: query
-          description: 'The video playlist privacy (Pending = `1`, Rejected = `2`, Accepted = `3`)'
+          description: 'The abuse state (Pending = `1`, Rejected = `2`, Accepted = `3`)'
           schema:
             type: integer
             enum:
@@ -1535,6 +1525,23 @@ paths:
           description: only list reports of a specific video channel
           schema:
             type: string
+        - name: videoIs
+          in: query
+          description: only list blacklisted or deleted videos
+          schema:
+            type: string
+            enum:
+            - 'deleted'
+            - 'blacklisted'
+        - name: filter
+          in: query
+          description: only list account, comment or video reports
+          schema:
+            type: string
+            enum:
+            - 'video'
+            - 'comment'
+            - 'account'
         - $ref: '#/components/parameters/start'
         - $ref: '#/components/parameters/count'
         - $ref: '#/components/parameters/abusesSort'
@@ -1547,17 +1554,13 @@ paths:
                 type: array
                 items:
                   $ref: '#/components/schemas/VideoAbuse'
-  '/videos/{id}/abuse':
+
     post:
-      deprecated: true
       summary: Report an abuse
       security:
         - OAuth2: []
       tags:
-        - Video Abuses
-        - Videos
-      parameters:
-        - $ref: '#/components/parameters/idOrUUID'
+        - Abuses
       requestBody:
         required: true
         content:
@@ -1570,27 +1573,34 @@ paths:
                   type: string
                   minLength: 4
                 predefinedReasons:
-                  description: Reason categories that help triage reports
-                  type: array
-                  items:
-                    type: string
-                    enum:
-                    - violentOrAbusive
-                    - hatefulOrAbusive
-                    - spamOrMisleading
-                    - privacy
-                    - rights
-                    - serverRules
-                    - thumbnails
-                    - captions
-                startAt:
-                  type: integer
-                  description: Timestamp in the video that marks the beginning of the report
-                  minimum: 0
-                endAt:
-                  type: integer
-                  description: Timestamp in the video that marks the ending of the report
-                  minimum: 0
+                  $ref: '#/components/schemas/PredefinedAbuseReasons'
+
+                video:
+                  type: object
+                  properties:
+                    id:
+                      description: Video id to report
+                      type: number
+                    startAt:
+                      type: integer
+                      description: Timestamp in the video that marks the beginning of the report
+                      minimum: 0
+                    endAt:
+                      type: integer
+                      description: Timestamp in the video that marks the ending of the report
+                      minimum: 0
+                comment:
+                  type: object
+                  properties:
+                    id:
+                      description: Comment id to report
+                      type: number
+                account:
+                  type: object
+                  properties:
+                    id:
+                      description: Account id to report
+                      type: number
               required:
                 - reason
       responses:
@@ -1598,18 +1608,16 @@ paths:
           description: successful operation
         '400':
           description: incorrect request parameters
-  '/videos/{id}/abuse/{abuseId}':
+  '/abuses/{abuseId}':
     put:
-      deprecated: true
       summary: Update an abuse
       security:
         - OAuth2:
           - admin
           - moderator
       tags:
-        - Video Abuses
+        - Abuses
       parameters:
-        - $ref: '#/components/parameters/idOrUUID'
         - $ref: '#/components/parameters/abuseId'
       requestBody:
         content:
@@ -1618,7 +1626,7 @@ paths:
               type: object
               properties:
                 state:
-                  $ref: '#/components/schemas/VideoAbuseStateSet'
+                  $ref: '#/components/schemas/AbuseStateSet'
                 moderationComment:
                   type: string
                   description: Update the report comment visible only to the moderation team
@@ -1626,18 +1634,16 @@ paths:
         '204':
           description: successful operation
         '404':
-          description: video abuse not found
+          description: abuse not found
     delete:
-      deprecated: true
       tags:
-        - Video Abuses
+        - Abuses
       summary: Delete an abuse
       security:
         - OAuth2:
             - admin
             - moderator
       parameters:
-        - $ref: '#/components/parameters/idOrUUID'
         - $ref: '#/components/parameters/abuseId'
       responses:
         '204':
@@ -3320,7 +3326,7 @@ components:
       name: abuseId
       in: path
       required: true
-      description: Video abuse id
+      description: Abuse id
       schema:
         type: integer
     captionLanguage:
@@ -3584,20 +3590,20 @@ components:
         label:
           type: string
 
-    VideoAbuseStateSet:
+    AbuseStateSet:
       type: integer
       enum:
         - 1
         - 2
         - 3
       description: 'The video playlist privacy (Pending = `1`, Rejected = `2`, Accepted = `3`)'
-    VideoAbuseStateConstant:
+    AbuseStateConstant:
       properties:
         id:
-          $ref: '#/components/schemas/VideoAbuseStateSet'
+          $ref: '#/components/schemas/AbuseStateSet'
         label:
           type: string
-    VideoAbusePredefinedReasons:
+    AbusePredefinedReasons:
       type: array
       items:
         type: string
@@ -3960,11 +3966,11 @@ components:
           type: string
           example: The video is a spam
         predefinedReasons:
-          $ref: '#/components/schemas/VideoAbusePredefinedReasons'
+          $ref: '#/components/schemas/AbusePredefinedReasons'
         reporterAccount:
           $ref: '#/components/schemas/Account'
         state:
-          $ref: '#/components/schemas/VideoAbuseStateConstant'
+          $ref: '#/components/schemas/AbuseStateConstant'
         moderationComment:
           type: string
           example: Decided to ban the server since it spams us regularly
@@ -4553,6 +4559,22 @@ components:
         updatedAt:
           type: string
           format: date-time
+
+    PredefinedAbuseReasons:
+      description: Reason categories that help triage reports
+      type: array
+      items:
+        type: string
+        enum:
+        - violentOrAbusive
+        - hatefulOrAbusive
+        - spamOrMisleading
+        - privacy
+        - rights
+        - serverRules
+        - thumbnails
+        - captions
+
     Job:
       properties:
         id:
@@ -4690,11 +4712,11 @@ components:
           description: The user daily video quota
         videosCount:
           type: integer
-        videoAbusesCount:
+        abusesCount:
           type: integer
-        videoAbusesAcceptedCount:
+        abusesAcceptedCount:
           type: integer
-        videoAbusesCreatedCount:
+        abusesCreatedCount:
           type: integer
         videoCommentsCount:
           type: integer
@@ -5098,7 +5120,7 @@ components:
 
             - `2` NEW_COMMENT_ON_MY_VIDEO
 
-            - `3` NEW_VIDEO_ABUSE_FOR_MODERATORS
+            - `3` NEW_ABUSE_FOR_MODERATORS
 
             - `4` BLACKLIST_ON_MY_VIDEO